Compare commits
1 Commits
main
...
e3f0eaa24d
| Author | SHA1 | Date | |
|---|---|---|---|
| e3f0eaa24d |
11
asn1/StudySystemProtocol.asn1
Normal file
11
asn1/StudySystemProtocol.asn1
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
StudySystemProtocol DEFINITIONS EXPLICIT TAGS ::= BEGIN
|
||||||
|
|
||||||
|
Request ::= CHOICE {
|
||||||
|
foo [0] NULL
|
||||||
|
}
|
||||||
|
|
||||||
|
Response ::= CHOICE {
|
||||||
|
msg [0] UTF8String
|
||||||
|
}
|
||||||
|
|
||||||
|
END
|
||||||
@@ -9,14 +9,6 @@ project(
|
|||||||
add_project_arguments('-w', language: 'c')
|
add_project_arguments('-w', language: 'c')
|
||||||
|
|
||||||
gtk_dep = dependency('gtk4')
|
gtk_dep = dependency('gtk4')
|
||||||
gnome = import('gnome')
|
|
||||||
|
|
||||||
resources = gnome.compile_resources(
|
|
||||||
'resources',
|
|
||||||
'resources.gresource.xml',
|
|
||||||
source_dir: '.',
|
|
||||||
c_name: 'resources'
|
|
||||||
)
|
|
||||||
|
|
||||||
subdir('src')
|
subdir('src')
|
||||||
subdir('tests')
|
subdir('tests')
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<gresources>
|
|
||||||
<gresource prefix="/sh/wip/studysystemclient">
|
|
||||||
<file>styles.css</file>
|
|
||||||
</gresource>
|
|
||||||
</gresources>
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
namespace StudySystemClient {
|
|
||||||
public class ActivitiesView : ListView<ActivityCard> {
|
|
||||||
private const uint REFRESH_PERIOD_MS = 30000;
|
|
||||||
|
|
||||||
private Client client;
|
|
||||||
private Updater updater;
|
|
||||||
private Refresher refresher;
|
|
||||||
private bool pending_sort;
|
|
||||||
|
|
||||||
public ActivitiesView(Client client) {
|
|
||||||
base();
|
|
||||||
add_css_class("card-container");
|
|
||||||
this.client = client;
|
|
||||||
updater = new Updater(this, client);
|
|
||||||
refresher = new Refresher(updater, REFRESH_PERIOD_MS);
|
|
||||||
pending_sort = false;
|
|
||||||
this.map.connect(() => {
|
|
||||||
updater.refresh.begin();
|
|
||||||
refresher.start();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
internal IterableBox.Iterator<Type, ActivityCard> iterator() {
|
|
||||||
return container.iterator();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void new_card(Activity activity) {
|
|
||||||
var card = new ActivityCard(activity);
|
|
||||||
card.session_logged.connect(log_session);
|
|
||||||
card.log_closed.connect(handle_pending_sort);
|
|
||||||
container.append(card);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void remove_card(ActivityCard card) {
|
|
||||||
container.remove(card);
|
|
||||||
}
|
|
||||||
|
|
||||||
internal void sort() {
|
|
||||||
if (log_in_progress())
|
|
||||||
pending_sort = true;
|
|
||||||
else
|
|
||||||
container.sort(compare_cards);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void log_session(string subject, ActivityType type,
|
|
||||||
int minutes) {
|
|
||||||
try {
|
|
||||||
yield client.log_session(subject, type, minutes);
|
|
||||||
yield updater.refresh();
|
|
||||||
} catch (ClientError e) {
|
|
||||||
stderr.printf("Error logging session: %s\n", e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handle_pending_sort() {
|
|
||||||
if (pending_sort) {
|
|
||||||
container.sort(compare_cards);
|
|
||||||
pending_sort = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool log_in_progress() {
|
|
||||||
foreach (var card in container) {
|
|
||||||
if (card.logging)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int compare_cards(ActivityCard card1,
|
|
||||||
ActivityCard card2) {
|
|
||||||
if (card1.activity.priority < card2.activity.priority)
|
|
||||||
return -1;
|
|
||||||
else if (card1.activity.priority > card2.activity.priority)
|
|
||||||
return 1;
|
|
||||||
else
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Updater : IRefreshable {
|
|
||||||
private weak ActivitiesView target;
|
|
||||||
private Client client;
|
|
||||||
|
|
||||||
public Updater(ActivitiesView target, Client client) {
|
|
||||||
this.target = target;
|
|
||||||
this.client = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void refresh() {
|
|
||||||
if (!client.connected || target == null)
|
|
||||||
return;
|
|
||||||
try {
|
|
||||||
var activities = yield client.list_activities();
|
|
||||||
apply_update(activities);
|
|
||||||
target.sort();
|
|
||||||
} catch (ClientError e) {
|
|
||||||
stderr.printf("Error refreshing activities: %s\n",
|
|
||||||
e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void apply_update(Array<Activity> activities) {
|
|
||||||
update_existing(activities);
|
|
||||||
for (uint i = 0; i < activities.length; ++i)
|
|
||||||
target.new_card(activities.index(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void update_existing(Array<Activity> activities) {
|
|
||||||
var to_remove = new List<ActivityCard>();
|
|
||||||
foreach (var card in target) {
|
|
||||||
if (!update_card(card, activities))
|
|
||||||
to_remove.append(card);
|
|
||||||
}
|
|
||||||
foreach (var card in to_remove)
|
|
||||||
target.remove_card(card);
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool update_card(ActivityCard card,
|
|
||||||
Array<Activity> activities) {
|
|
||||||
var activity_index = find_activity(card, activities);
|
|
||||||
if (activity_index == null)
|
|
||||||
return false;
|
|
||||||
var priority = activities.index(activity_index).priority;
|
|
||||||
card.update_priority(priority);
|
|
||||||
activities._remove_index_fast(activity_index);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static uint? find_activity(ActivityCard card,
|
|
||||||
Array<Activity> activities) {
|
|
||||||
for (uint i = 0; i < activities.length; ++i) {
|
|
||||||
if (activities.index(i).subject == card.activity.subject
|
|
||||||
&& activities.index(i).type == card.activity.type)
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
namespace StudySystemClient {
|
|
||||||
public struct Activity {
|
|
||||||
public string subject;
|
|
||||||
public ActivityType type;
|
|
||||||
public double priority;
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum ActivityType {
|
|
||||||
READING = 0,
|
|
||||||
EXERCISES = 1,
|
|
||||||
|
|
||||||
COUNT;
|
|
||||||
|
|
||||||
public string to_string() {
|
|
||||||
switch (this) {
|
|
||||||
case EXERCISES:
|
|
||||||
return "Exercises";
|
|
||||||
case READING:
|
|
||||||
return "Reading";
|
|
||||||
default:
|
|
||||||
return "Invalid activity type";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
namespace StudySystemClient {
|
|
||||||
public class ActivityCard : Gtk.Frame {
|
|
||||||
public signal void session_logged(string subject, ActivityType type,
|
|
||||||
int minutes);
|
|
||||||
public signal void log_closed();
|
|
||||||
|
|
||||||
public Activity activity { get; private set; }
|
|
||||||
public bool logging { get; private set; }
|
|
||||||
|
|
||||||
private Gtk.Label priority_label;
|
|
||||||
|
|
||||||
public ActivityCard(Activity activity) {
|
|
||||||
hexpand = true;
|
|
||||||
add_css_class("card");
|
|
||||||
|
|
||||||
this.activity = activity;
|
|
||||||
logging = false;
|
|
||||||
|
|
||||||
var subject = new Gtk.Label(activity.subject);
|
|
||||||
subject.halign = Gtk.Align.START;
|
|
||||||
subject.add_css_class("activity-subject");
|
|
||||||
|
|
||||||
var type = new Gtk.Label(activity.type.to_string());
|
|
||||||
type.add_css_class("activity-type");
|
|
||||||
var separator = new Gtk.Label("·");
|
|
||||||
separator.add_css_class("activity-priority");
|
|
||||||
|
|
||||||
priority_label = new Gtk.Label(priority_text(activity.priority));
|
|
||||||
priority_label.add_css_class("activity-priority");
|
|
||||||
|
|
||||||
var details = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6);
|
|
||||||
details.append(type);
|
|
||||||
details.append(separator);
|
|
||||||
details.append(priority_label);
|
|
||||||
|
|
||||||
var text = new Gtk.Box(Gtk.Orientation.VERTICAL, 6);
|
|
||||||
text.hexpand = true;
|
|
||||||
text.append(subject);
|
|
||||||
text.append(details);
|
|
||||||
|
|
||||||
var content = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 12);
|
|
||||||
content.append(text);
|
|
||||||
|
|
||||||
var button
|
|
||||||
= new Gtk.Button.from_icon_name("appointment-new-symbolic");
|
|
||||||
button.vexpand = false;
|
|
||||||
button.valign = Gtk.Align.CENTER;
|
|
||||||
button.set_tooltip_text("Log session");
|
|
||||||
button.add_css_class("log-session-button");
|
|
||||||
content.append(button);
|
|
||||||
|
|
||||||
set_child(content);
|
|
||||||
|
|
||||||
var log_session_popover = new LogSessionPopover();
|
|
||||||
log_session_popover.set_parent(button);
|
|
||||||
log_session_popover.closed.connect(() => {
|
|
||||||
logging = false;
|
|
||||||
log_closed();
|
|
||||||
});
|
|
||||||
log_session_popover.session_logged.connect((minutes) => {
|
|
||||||
session_logged(activity.subject, activity.type, minutes);
|
|
||||||
});
|
|
||||||
|
|
||||||
button.clicked.connect(() => {
|
|
||||||
logging = true;
|
|
||||||
log_session_popover.popup();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public void update_priority(double new_priority) {
|
|
||||||
activity = { activity.subject, activity.type, new_priority };
|
|
||||||
priority_label.set_text(priority_text(new_priority));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string priority_text(double priority) {
|
|
||||||
return "%0.2f".printf(priority);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class LogSessionPopover : Gtk.Popover {
|
|
||||||
public signal void session_logged(int minutes);
|
|
||||||
|
|
||||||
private const int DEFAULT_LENGTH = 30;
|
|
||||||
|
|
||||||
private Gtk.SpinButton input;
|
|
||||||
|
|
||||||
public LogSessionPopover() {
|
|
||||||
var content = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6);
|
|
||||||
|
|
||||||
var label = new Gtk.Label("Minutes");
|
|
||||||
label.halign = Gtk.Align.START;
|
|
||||||
content.append(label);
|
|
||||||
|
|
||||||
var adjustment
|
|
||||||
= new Gtk.Adjustment(DEFAULT_LENGTH, 10, 480, 10, 10, 0);
|
|
||||||
input = new Gtk.SpinButton(adjustment, 1, 0);
|
|
||||||
input.numeric = true;
|
|
||||||
content.append(input);
|
|
||||||
|
|
||||||
var button = new Gtk.Button.from_icon_name("emblem-ok-symbolic");
|
|
||||||
button.halign = Gtk.Align.END;
|
|
||||||
button.set_tooltip_text("Submit");
|
|
||||||
button.add_css_class("suggested-action");
|
|
||||||
button.clicked.connect(submit);
|
|
||||||
content.append(button);
|
|
||||||
|
|
||||||
set_child(content);
|
|
||||||
|
|
||||||
closed.connect(reset);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void submit() {
|
|
||||||
session_logged((int)input.value);
|
|
||||||
reset();
|
|
||||||
popdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void reset() {
|
|
||||||
input.value = DEFAULT_LENGTH;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
namespace StudySystemClient {
|
|
||||||
public errordomain ClientError {
|
|
||||||
ERROR_RESPONSE,
|
|
||||||
UNEXPECTED_RESPONSE,
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Client {
|
|
||||||
public signal void connection_status(bool connected);
|
|
||||||
|
|
||||||
public bool connected { get { return connection.connected; } }
|
|
||||||
|
|
||||||
private Connection connection;
|
|
||||||
|
|
||||||
public Client(string cert_dir) throws Error {
|
|
||||||
connection = new Connection(cert_dir, (connected) => {
|
|
||||||
connection_status(connected);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void ping() throws ClientError {
|
|
||||||
var response = yield connection.send(new Request.Ping());
|
|
||||||
if (response is Response.Ack) {
|
|
||||||
return;
|
|
||||||
} else if (response is Response.Error) {
|
|
||||||
throw new ClientError.ERROR_RESPONSE(
|
|
||||||
"Error response to Ping: %s",
|
|
||||||
response.value.to_string());
|
|
||||||
} else {
|
|
||||||
throw new ClientError.UNEXPECTED_RESPONSE(
|
|
||||||
"Unexpected response to Ping");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Array<Activity> list_activities()
|
|
||||||
throws ClientError {
|
|
||||||
var request = new Request.ListActivities();
|
|
||||||
var response = yield connection.send(request);
|
|
||||||
if (response is Response.Activities) {
|
|
||||||
return response.value;
|
|
||||||
} else if (response is Response.Error) {
|
|
||||||
throw new ClientError.ERROR_RESPONSE(
|
|
||||||
"Error response to ListActivities: %s",
|
|
||||||
response.value.to_string());
|
|
||||||
} else {
|
|
||||||
throw new ClientError.UNEXPECTED_RESPONSE(
|
|
||||||
"Unexpected response to ListActivities");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async void log_session(string subject, ActivityType type,
|
|
||||||
int minutes) throws ClientError {
|
|
||||||
var timestamp = new DateTime.now_utc().to_unix();
|
|
||||||
var request
|
|
||||||
= new Request.LogSession(subject, type, timestamp, minutes);
|
|
||||||
var response = yield connection.send(request);
|
|
||||||
if (response is Response.Ack) {
|
|
||||||
return;
|
|
||||||
} else if (response is Response.Error) {
|
|
||||||
throw new ClientError.ERROR_RESPONSE(
|
|
||||||
"Error response to LogSession: %s",
|
|
||||||
response.value.to_string());
|
|
||||||
} else {
|
|
||||||
throw new ClientError.UNEXPECTED_RESPONSE(
|
|
||||||
"Unexpected response to LogSession");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,122 +1,221 @@
|
|||||||
namespace StudySystemClient {
|
namespace StudySystemClient {
|
||||||
public class Connection {
|
public class Connection {
|
||||||
public delegate void StatusCallback(bool connected);
|
public signal void received(uint8[] msg);
|
||||||
|
|
||||||
public bool connected { get; private set; }
|
|
||||||
|
|
||||||
private StatusCallback status_callback;
|
|
||||||
private SessionManager session_manager;
|
private SessionManager session_manager;
|
||||||
private TransactionManager transaction_manager;
|
|
||||||
private Worker worker;
|
private Worker worker;
|
||||||
|
|
||||||
public Connection(string cert_dir,
|
public Connection(string cert_dir) throws Error {
|
||||||
owned StatusCallback status_callback)
|
|
||||||
throws Error {
|
|
||||||
var loopback = new InetAddress.loopback(SocketFamily.IPV6);
|
var loopback = new InetAddress.loopback(SocketFamily.IPV6);
|
||||||
var session_factory
|
var session_factory
|
||||||
= new SessionFactory(loopback, 12888, cert_dir);
|
= new SessionFactory(loopback, 12888, cert_dir);
|
||||||
this.status_callback = (owned) status_callback;
|
|
||||||
session_manager = new SessionManager(
|
session_manager = new SessionManager(
|
||||||
session_factory, (msg) => receive(msg),
|
session_factory, (msg) => {
|
||||||
(connected) => update_status(connected));
|
var msg_copy = new uint8[msg.length];
|
||||||
transaction_manager = new TransactionManager();
|
Memory.copy(msg_copy, msg, msg.length);
|
||||||
|
Idle.add(() => {
|
||||||
|
received(msg_copy);
|
||||||
|
return false;
|
||||||
|
}, GLib.Priority.DEFAULT_IDLE);
|
||||||
|
});
|
||||||
worker = new Worker(session_manager);
|
worker = new Worker(session_manager);
|
||||||
connected = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Response.Body? send(Request.Body body) {
|
public void send(owned uint8[] msg) {
|
||||||
var transaction_id = transaction_manager.register(send.callback);
|
session_manager.send(msg);
|
||||||
var request = new Request.Request(transaction_id, body);
|
|
||||||
session_manager.send(request.encode());
|
|
||||||
yield;
|
|
||||||
return transaction_manager.get_result(transaction_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void receive(owned uint8[] msg) {
|
|
||||||
Response.Response response;
|
|
||||||
try {
|
|
||||||
response = new Response.Response.from_bytes(msg);
|
|
||||||
} catch (Response.DecodeError e) {
|
|
||||||
stderr.printf("Invalid response from server: %s", e.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Idle.add(() => {
|
|
||||||
transaction_manager.resolve(response);
|
|
||||||
return false;
|
|
||||||
}, GLib.Priority.DEFAULT_IDLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void update_status(bool connected) {
|
|
||||||
Idle.add(() => {
|
|
||||||
if (connected != this.connected) {
|
|
||||||
this.connected = connected;
|
|
||||||
status_callback(connected);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, GLib.Priority.DEFAULT_IDLE);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Continuation {
|
private class Worker {
|
||||||
private SourceFunc callback;
|
private uint TASK_PERIOD_MS = 10;
|
||||||
|
|
||||||
public Continuation(owned SourceFunc callback) {
|
|
||||||
this.callback = (owned) callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void resume() {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TransactionManager {
|
|
||||||
private uint16 next_transaction_id;
|
|
||||||
private HashTable<uint16, Continuation> pending;
|
|
||||||
private HashTable<uint16, Response.Body> results;
|
|
||||||
|
|
||||||
public TransactionManager() {
|
|
||||||
next_transaction_id = 0;
|
|
||||||
pending = new HashTable<uint16, Continuation>(null, null);
|
|
||||||
results = new HashTable<uint16, Response.Body>(null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public uint16 register(owned SourceFunc callback) {
|
|
||||||
var transaction_id = next_transaction_id++;
|
|
||||||
pending.insert(transaction_id,
|
|
||||||
new Continuation((owned) callback));
|
|
||||||
return transaction_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void resolve(Response.Response response) {
|
|
||||||
var transaction_id = (uint16)response.transaction_id;
|
|
||||||
var continuation = pending.lookup(transaction_id);
|
|
||||||
if (continuation == null) {
|
|
||||||
stderr.printf("Response for non-pending transaction %d\n",
|
|
||||||
transaction_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
results.insert(transaction_id, response.body);
|
|
||||||
continuation.resume();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Response.Body? get_result(uint16 transaction_id) {
|
|
||||||
return results.lookup(transaction_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Worker : Periodic {
|
|
||||||
private const uint TASK_PERIOD_MS = 10;
|
|
||||||
|
|
||||||
private SessionManager session_manager;
|
private SessionManager session_manager;
|
||||||
|
private bool exit;
|
||||||
|
private Thread<void> thread;
|
||||||
|
|
||||||
public Worker(SessionManager session_manager) {
|
public Worker(SessionManager session_manager) {
|
||||||
base(TASK_PERIOD_MS);
|
|
||||||
this.session_manager = session_manager;
|
this.session_manager = session_manager;
|
||||||
start();
|
exit = false;
|
||||||
|
thread = new Thread<void>("connection_worker", body);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void task() {
|
~Worker() {
|
||||||
session_manager.task();
|
exit = true;
|
||||||
|
thread.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void body() {
|
||||||
|
while (!exit) {
|
||||||
|
session_manager.task();
|
||||||
|
Thread.usleep(1000 * TASK_PERIOD_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SessionManager {
|
||||||
|
public delegate void ReceiveCallback(uint8[] msg);
|
||||||
|
|
||||||
|
private const uint INIT_RECONNECT_WAIT_MS = 500;
|
||||||
|
private const uint MAX_RECONNECT_WAIT_MS = 60000;
|
||||||
|
private const double RECONNECT_BACKOFF = 1.6;
|
||||||
|
|
||||||
|
private SessionFactory session_factory;
|
||||||
|
private ReceiveCallback receive_callback;
|
||||||
|
private Session? session;
|
||||||
|
private AsyncQueue<OutgoingMessage> queue;
|
||||||
|
private uint reconnect_wait_ms;
|
||||||
|
|
||||||
|
public SessionManager(SessionFactory session_factory,
|
||||||
|
owned ReceiveCallback receive_callback) {
|
||||||
|
this.session_factory = session_factory;
|
||||||
|
this.receive_callback = (owned) receive_callback;
|
||||||
|
this.session = null;
|
||||||
|
queue = new AsyncQueue<OutgoingMessage>();
|
||||||
|
reconnect_wait_ms = INIT_RECONNECT_WAIT_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void send(uint8[] msg) {
|
||||||
|
queue.push(new OutgoingMessage(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void task() {
|
||||||
|
if (session != null) {
|
||||||
|
var failed_msg = session.task(queue);
|
||||||
|
if (failed_msg != null)
|
||||||
|
handle_failed_msg(failed_msg);
|
||||||
|
} else {
|
||||||
|
try_start_session();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handle_failed_msg(OutgoingMessage msg) {
|
||||||
|
msg.has_failed();
|
||||||
|
if (msg.should_retry())
|
||||||
|
queue.push(msg);
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void try_start_session() {
|
||||||
|
try {
|
||||||
|
session = session_factory.start_session();
|
||||||
|
session.received.connect(
|
||||||
|
(msg) => receive_callback(msg));
|
||||||
|
reconnect_wait_ms = INIT_RECONNECT_WAIT_MS;
|
||||||
|
} catch (Error _) {
|
||||||
|
Thread.usleep(1000 * reconnect_wait_ms);
|
||||||
|
update_reconnect_wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void update_reconnect_wait() {
|
||||||
|
var new_wait = RECONNECT_BACKOFF * reconnect_wait_ms;
|
||||||
|
if (new_wait < MAX_RECONNECT_WAIT_MS)
|
||||||
|
reconnect_wait_ms = (uint)new_wait;
|
||||||
|
else
|
||||||
|
reconnect_wait_ms = MAX_RECONNECT_WAIT_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SessionFactory {
|
||||||
|
private const string CA_FILENAME = "/ca.pem";
|
||||||
|
private const string CERT_FILENAME = "/client.pem";
|
||||||
|
private const uint TIMEOUT_S = 1;
|
||||||
|
|
||||||
|
private InetSocketAddress host;
|
||||||
|
private TlsCertificate cert;
|
||||||
|
private TlsDatabase ca_db;
|
||||||
|
|
||||||
|
public SessionFactory(InetAddress host_addr, uint16 host_port,
|
||||||
|
string cert_dir) throws Error {
|
||||||
|
host = new InetSocketAddress(host_addr, host_port);
|
||||||
|
var cert_path = cert_dir + CERT_FILENAME;
|
||||||
|
cert = new TlsCertificate.from_file(cert_path);
|
||||||
|
var ca_path = cert_dir + CA_FILENAME;
|
||||||
|
var db_type = TlsBackend.get_default().get_file_database_type();
|
||||||
|
ca_db = Object.new(db_type, "anchors", ca_path) as TlsDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Session start_session() throws Error {
|
||||||
|
var plain_client = new SocketClient();
|
||||||
|
plain_client.set_timeout(TIMEOUT_S);
|
||||||
|
var plain_connection = plain_client.connect(host);
|
||||||
|
var connection = TlsClientConnection.new(plain_connection, host);
|
||||||
|
connection.set_database(ca_db);
|
||||||
|
connection.set_certificate(cert);
|
||||||
|
connection.handshake();
|
||||||
|
return new Session(connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Session {
|
||||||
|
public signal void received(uint8[] msg);
|
||||||
|
|
||||||
|
private const uint MAX_BATCH_SIZE = 10;
|
||||||
|
private const uint MAX_MSG_LEN = 1024;
|
||||||
|
|
||||||
|
private TlsClientConnection connection;
|
||||||
|
|
||||||
|
public Session(TlsClientConnection connection) {
|
||||||
|
this.connection = connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OutgoingMessage? task(AsyncQueue<OutgoingMessage> queue) {
|
||||||
|
for (int i = 0; i < MAX_BATCH_SIZE; ++i) {
|
||||||
|
if (queue.length() == 0)
|
||||||
|
break;
|
||||||
|
var msg = queue.pop();
|
||||||
|
var success = true;
|
||||||
|
success &= send(msg);
|
||||||
|
success &= receive();
|
||||||
|
if (!success)
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool send(OutgoingMessage msg) {
|
||||||
|
try {
|
||||||
|
size_t written;
|
||||||
|
connection.output_stream.write_all(msg.content, out written);
|
||||||
|
return true;
|
||||||
|
} catch (IOError _) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool receive() {
|
||||||
|
try {
|
||||||
|
var buffer = new uint8[MAX_MSG_LEN];
|
||||||
|
var len = connection.input_stream.read(buffer);
|
||||||
|
if (len <= 0)
|
||||||
|
return false;
|
||||||
|
received(buffer[0:len]);
|
||||||
|
return true;
|
||||||
|
} catch (IOError _) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OutgoingMessage {
|
||||||
|
public uint8[] content { get; private set; }
|
||||||
|
|
||||||
|
private const uint MAX_FAIL_COUNT = 4;
|
||||||
|
|
||||||
|
private uint fail_count;
|
||||||
|
|
||||||
|
public OutgoingMessage(owned uint8[] content) {
|
||||||
|
this.content = (owned)content;
|
||||||
|
fail_count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void has_failed() {
|
||||||
|
++fail_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool should_retry() {
|
||||||
|
return fail_count < MAX_FAIL_COUNT;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ namespace StudySystemClient.Der {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const uint BASE_HEADER_SIZE = 2;
|
private const uint BASE_HEADER_SIZE = 2;
|
||||||
private const uint8 MAX_INT64_BYTES = 8;
|
|
||||||
|
|
||||||
public static Datum decode(uint8[] bytes, out uint? size = null)
|
public static Datum decode(uint8[] bytes, out uint? size = null)
|
||||||
throws DecodeError {
|
throws DecodeError {
|
||||||
@@ -62,8 +61,6 @@ namespace StudySystemClient.Der {
|
|||||||
return new Sequence.from_content(content);
|
return new Sequence.from_content(content);
|
||||||
case Null.TYPE:
|
case Null.TYPE:
|
||||||
return new Null.from_content(content);
|
return new Null.from_content(content);
|
||||||
case Enumerated.TYPE:
|
|
||||||
return new Enumerated.from_content(content);
|
|
||||||
default:
|
default:
|
||||||
throw new DecodeError.UNKNOWN_TYPE("Unsupported type: %02x",
|
throw new DecodeError.UNKNOWN_TYPE("Unsupported type: %02x",
|
||||||
type);
|
type);
|
||||||
@@ -71,8 +68,8 @@ namespace StudySystemClient.Der {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public abstract class Datum {
|
public abstract class Datum {
|
||||||
protected uint8 type;
|
internal uint8 type;
|
||||||
protected uint8[] content;
|
internal uint8[] content;
|
||||||
|
|
||||||
public uint8[] encode() {
|
public uint8[] encode() {
|
||||||
var buffer = new ByteArray();
|
var buffer = new ByteArray();
|
||||||
@@ -138,6 +135,7 @@ namespace StudySystemClient.Der {
|
|||||||
|
|
||||||
public class Integer : Datum {
|
public class Integer : Datum {
|
||||||
internal const uint8 TYPE = 0x02;
|
internal const uint8 TYPE = 0x02;
|
||||||
|
private const uint8 MAX_BYTES = 8;
|
||||||
|
|
||||||
public int64 value { get; private set; }
|
public int64 value { get; private set; }
|
||||||
|
|
||||||
@@ -152,6 +150,59 @@ namespace StudySystemClient.Der {
|
|||||||
content = bytes;
|
content = bytes;
|
||||||
value = decode_int64(content);
|
value = decode_int64(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static uint64 twos_complement(uint64 x) {
|
||||||
|
return ~x + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int min_bits(bool negative, uint64 x) {
|
||||||
|
int n = 0;
|
||||||
|
if (negative) {
|
||||||
|
while ((x >> (n + 8) & 0xff) != 0xff)
|
||||||
|
n += 8;
|
||||||
|
} else {
|
||||||
|
while (x >> (n + 8) > 0)
|
||||||
|
n += 8;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint8[] encode_int64(int64 val) {
|
||||||
|
var negative = val < 0;
|
||||||
|
var uval = negative ? twos_complement(val.abs()) : val;
|
||||||
|
var shift = min_bits(negative, uval);
|
||||||
|
|
||||||
|
var buffer = new ByteArray();
|
||||||
|
for (; shift >= 0; shift -= 8)
|
||||||
|
buffer.append({(uint8)(uval >> shift)});
|
||||||
|
|
||||||
|
if (!negative && (buffer.data[0] & 0x80) != 0)
|
||||||
|
buffer.prepend({0x00});
|
||||||
|
|
||||||
|
return buffer.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int64 decode_int64(uint8[] bytes) throws DecodeError {
|
||||||
|
if (bytes.length > MAX_BYTES) {
|
||||||
|
throw new DecodeError.INVALID_CONTENT(
|
||||||
|
"int64 too small for %u bytes", bytes.length);
|
||||||
|
}
|
||||||
|
var negative = (bytes[0] & 0x80) != 0;
|
||||||
|
var val = decode_start_val(negative, bytes.length);
|
||||||
|
foreach (var byte in bytes)
|
||||||
|
val = val << 8 | byte;
|
||||||
|
return negative ? -(int64)twos_complement(val) : (int64)val;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint64 decode_start_val(bool negative, uint length)
|
||||||
|
{
|
||||||
|
if (!negative)
|
||||||
|
return 0;
|
||||||
|
var val = 0;
|
||||||
|
for (uint i = 0; i < MAX_BYTES - length; ++i)
|
||||||
|
val = val << 8 | 0xff;
|
||||||
|
return val;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Utf8String : Datum {
|
public class Utf8String : Datum {
|
||||||
@@ -256,75 +307,4 @@ namespace StudySystemClient.Der {
|
|||||||
content = bytes;
|
content = bytes;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class Enumerated : Datum {
|
|
||||||
internal const uint8 TYPE = 0x0a;
|
|
||||||
|
|
||||||
public int64 value { get; private set; }
|
|
||||||
|
|
||||||
public Enumerated(int64 val) {
|
|
||||||
type = TYPE;
|
|
||||||
content = encode_int64(val);
|
|
||||||
value = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal Enumerated.from_content(uint8[] bytes) throws DecodeError {
|
|
||||||
type = TYPE;
|
|
||||||
content = bytes;
|
|
||||||
value = decode_int64(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static uint64 twos_complement(uint64 x) {
|
|
||||||
return ~x + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int min_bits(bool negative, uint64 x) {
|
|
||||||
int n = 0;
|
|
||||||
if (negative) {
|
|
||||||
while ((x >> (n + 8) & 0xff) != 0xff)
|
|
||||||
n += 8;
|
|
||||||
} else {
|
|
||||||
while (x >> (n + 8) > 0)
|
|
||||||
n += 8;
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static uint8[] encode_int64(int64 val) {
|
|
||||||
var negative = val < 0;
|
|
||||||
var uval = negative ? twos_complement(val.abs()) : val;
|
|
||||||
var shift = min_bits(negative, uval);
|
|
||||||
|
|
||||||
var buffer = new ByteArray();
|
|
||||||
for (; shift >= 0; shift -= 8)
|
|
||||||
buffer.append({(uint8)(uval >> shift)});
|
|
||||||
|
|
||||||
if (!negative && (buffer.data[0] & 0x80) != 0)
|
|
||||||
buffer.prepend({0x00});
|
|
||||||
|
|
||||||
return buffer.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int64 decode_int64(uint8[] bytes) throws DecodeError {
|
|
||||||
if (bytes.length > MAX_INT64_BYTES) {
|
|
||||||
throw new DecodeError.INVALID_CONTENT(
|
|
||||||
"int64 too small for %u bytes", bytes.length);
|
|
||||||
}
|
|
||||||
var negative = (bytes[0] & 0x80) != 0;
|
|
||||||
var val = decode_start_val(negative, bytes.length);
|
|
||||||
foreach (var byte in bytes)
|
|
||||||
val = val << 8 | byte;
|
|
||||||
return negative ? -(int64)twos_complement(val) : (int64)val;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static uint64 decode_start_val(bool negative, uint length)
|
|
||||||
{
|
|
||||||
if (!negative)
|
|
||||||
return 0;
|
|
||||||
var val = 0;
|
|
||||||
for (uint i = 0; i < MAX_INT64_BYTES - length; ++i)
|
|
||||||
val = val << 8 | 0xff;
|
|
||||||
return val;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
namespace StudySystemClient {
|
|
||||||
public class IterableBox<T> : Gtk.Box {
|
|
||||||
private List<T> elements;
|
|
||||||
|
|
||||||
public IterableBox(Gtk.Orientation orientation, int spacing) {
|
|
||||||
this.orientation = orientation;
|
|
||||||
this.spacing = spacing;
|
|
||||||
elements = new List<T>();
|
|
||||||
}
|
|
||||||
|
|
||||||
public new void append(T element) {
|
|
||||||
elements.append(element);
|
|
||||||
base.append(element as Gtk.Widget);
|
|
||||||
}
|
|
||||||
|
|
||||||
public new void remove(T element) {
|
|
||||||
elements.remove(element);
|
|
||||||
base.remove(element as Gtk.Widget);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Iterator<Type, T> iterator() {
|
|
||||||
return new Iterator<Type, T>(elements);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void sort(CompareDataFunc<T> comparison) {
|
|
||||||
elements.sort_with_data(comparison);
|
|
||||||
foreach (var element in elements)
|
|
||||||
reorder_child_after(element as Gtk.Widget, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Iterator<Type, T> {
|
|
||||||
private unowned List<T> head;
|
|
||||||
private unowned T value;
|
|
||||||
|
|
||||||
public Iterator(List<T> elements) {
|
|
||||||
head = elements;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool next() {
|
|
||||||
if (head.is_empty())
|
|
||||||
return false;
|
|
||||||
value = head.data;
|
|
||||||
head = head.next;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public unowned T get() {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
namespace StudySystemClient {
|
|
||||||
public class ListView<T> : Gtk.Box {
|
|
||||||
protected IterableBox<T> container;
|
|
||||||
|
|
||||||
public ListView() {
|
|
||||||
hexpand = vexpand = true;
|
|
||||||
margin_top = margin_bottom = margin_start = margin_end = 0;
|
|
||||||
|
|
||||||
container = new IterableBox<T>(Gtk.Orientation.VERTICAL, 6);
|
|
||||||
container.valign = Gtk.Align.START;
|
|
||||||
|
|
||||||
var scrolled_window = new Gtk.ScrolledWindow();
|
|
||||||
scrolled_window.hscrollbar_policy = Gtk.PolicyType.NEVER;
|
|
||||||
scrolled_window.hexpand = scrolled_window.vexpand = true;
|
|
||||||
scrolled_window.set_child(container);
|
|
||||||
|
|
||||||
append(scrolled_window);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Gtk;
|
||||||
|
|
||||||
namespace StudySystemClient {
|
namespace StudySystemClient {
|
||||||
public class App : Gtk.Application {
|
public class App : Gtk.Application {
|
||||||
public App() {
|
public App() {
|
||||||
@@ -6,20 +8,11 @@ namespace StudySystemClient {
|
|||||||
|
|
||||||
protected override void activate() {
|
protected override void activate() {
|
||||||
try {
|
try {
|
||||||
var css_provider = new Gtk.CssProvider();
|
var connection = new Connection(Config.CERT_DIR);
|
||||||
css_provider.load_from_resource(
|
new MainWindow(this, connection);
|
||||||
"/sh/wip/studysystemclient/styles.css");
|
|
||||||
Gtk.StyleContext.add_provider_for_display(
|
|
||||||
Gdk.Display.get_default(),
|
|
||||||
css_provider,
|
|
||||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION);
|
|
||||||
|
|
||||||
var client = new Client(Config.CERT_DIR);
|
|
||||||
var main_window = new MainWindow(this, client);
|
|
||||||
main_window.present();
|
|
||||||
} catch (Error e) {
|
} catch (Error e) {
|
||||||
stderr.printf("Failed to initialize: %s\n", e.message);
|
stderr.printf("Failed to initialize connection: %s\n",
|
||||||
return;
|
e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,44 @@
|
|||||||
namespace StudySystemClient {
|
namespace StudySystemClient {
|
||||||
|
const string title = "Study System Client";
|
||||||
|
|
||||||
public class MainWindow : Gtk.ApplicationWindow {
|
public class MainWindow : Gtk.ApplicationWindow {
|
||||||
public MainWindow(Gtk.Application app, Client client) {
|
private Connection connection;
|
||||||
|
private Gtk.Button send_button;
|
||||||
|
private Gtk.Label response_label;
|
||||||
|
|
||||||
|
public MainWindow(Gtk.Application app, Connection connection) {
|
||||||
Object(application: app);
|
Object(application: app);
|
||||||
|
|
||||||
default_width = 360;
|
default_width = 360;
|
||||||
default_height = 580;
|
default_height = 580;
|
||||||
|
|
||||||
var header_bar = new Gtk.HeaderBar();
|
this.connection = connection;
|
||||||
var title = new Gtk.Label("Study System Client");
|
connection.received.connect((msg) => {
|
||||||
title.add_css_class("title");
|
var der = Der.decode(msg) as Der.Choice;
|
||||||
header_bar.title_widget = title;
|
var str = der.value as Der.Utf8String;
|
||||||
set_titlebar(header_bar);
|
response_label.label = "Response: " + str.value;
|
||||||
|
|
||||||
var connection_indicator = new ConnectionIndicator(client);
|
|
||||||
var activities_view = new ActivitiesView(client);
|
|
||||||
|
|
||||||
var content = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
|
|
||||||
content.append(connection_indicator);
|
|
||||||
content.append(activities_view);
|
|
||||||
|
|
||||||
set_child(content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ConnectionIndicator : Gtk.Box {
|
|
||||||
public ConnectionIndicator(Client client) {
|
|
||||||
var icon
|
|
||||||
= new Gtk.Image.from_icon_name("network-offline-symbolic");
|
|
||||||
var label = new Gtk.Label("Disconnected");
|
|
||||||
|
|
||||||
var content = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
|
|
||||||
content.margin_top = content.margin_bottom
|
|
||||||
= content.margin_start = content.margin_end = 12;
|
|
||||||
content.hexpand = true;
|
|
||||||
content.halign = Gtk.Align.CENTER;
|
|
||||||
content.append(icon);
|
|
||||||
content.append(label);
|
|
||||||
|
|
||||||
var revealer = new Gtk.Revealer();
|
|
||||||
revealer.set_child(content);
|
|
||||||
append(revealer);
|
|
||||||
|
|
||||||
client.connection_status.connect((connected) => {
|
|
||||||
revealer.reveal_child = !connected;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var box = new Gtk.Box(Gtk.Orientation.VERTICAL, 10);
|
||||||
|
box.margin_start = 10;
|
||||||
|
box.margin_end = 10;
|
||||||
|
box.margin_top = 10;
|
||||||
|
box.margin_bottom = 10;
|
||||||
|
|
||||||
|
send_button = new Gtk.Button.with_label("Send");
|
||||||
|
send_button.clicked.connect(() => {
|
||||||
|
var msg = new Der.Choice(0, new Der.Null());
|
||||||
|
connection.send(msg.encode());
|
||||||
|
});
|
||||||
|
box.append(send_button);
|
||||||
|
|
||||||
|
response_label = new Gtk.Label("");
|
||||||
|
response_label.wrap = true;
|
||||||
|
box.append(response_label);
|
||||||
|
|
||||||
|
set_child(box);
|
||||||
|
|
||||||
|
present();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,12 @@ configure_file(
|
|||||||
lib = library(
|
lib = library(
|
||||||
'study-system-client',
|
'study-system-client',
|
||||||
sources: files(
|
sources: files(
|
||||||
'activities_view.vala',
|
|
||||||
'activity.vala',
|
|
||||||
'activity_card.vala',
|
|
||||||
'client.vala',
|
|
||||||
'connection.vala',
|
'connection.vala',
|
||||||
'der.vala',
|
'der.vala',
|
||||||
'iterable_box.vala',
|
|
||||||
'list_view.vala',
|
|
||||||
'main_window.vala',
|
'main_window.vala',
|
||||||
'periodic.vala',
|
),
|
||||||
'refresher.vala',
|
|
||||||
'request.vala',
|
|
||||||
'response.vala',
|
|
||||||
'session_manager.vala',
|
|
||||||
) + resources,
|
|
||||||
dependencies: [gtk_dep],
|
dependencies: [gtk_dep],
|
||||||
vala_vapi: 'study-system-client.vapi',
|
vala_vapi: 'study-system-client.vapi'
|
||||||
vala_args: ['--pkg', 'gtk4']
|
|
||||||
)
|
)
|
||||||
lib_dep = declare_dependency(
|
lib_dep = declare_dependency(
|
||||||
link_with: lib,
|
link_with: lib,
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
namespace StudySystemClient {
|
|
||||||
public abstract class Periodic {
|
|
||||||
private uint period_ms;
|
|
||||||
private bool exit;
|
|
||||||
private Thread<void> thread;
|
|
||||||
|
|
||||||
protected Periodic(uint period_ms) {
|
|
||||||
this.period_ms = period_ms;
|
|
||||||
exit = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
~Periodic() {
|
|
||||||
if (!exit) {
|
|
||||||
exit = true;
|
|
||||||
thread.join();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected abstract void task();
|
|
||||||
|
|
||||||
public void start() {
|
|
||||||
if (exit) {
|
|
||||||
exit = false;
|
|
||||||
thread = new Thread<void>("Periodic task", body);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void body() {
|
|
||||||
while (!exit) {
|
|
||||||
task();
|
|
||||||
Thread.usleep(1000 * period_ms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
namespace StudySystemClient {
|
|
||||||
public interface IRefreshable {
|
|
||||||
public abstract async void refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Refresher : Periodic {
|
|
||||||
private weak IRefreshable target;
|
|
||||||
|
|
||||||
public Refresher(IRefreshable target, uint period_ms) {
|
|
||||||
base(period_ms);
|
|
||||||
this.target = target;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void task() {
|
|
||||||
if (target != null) {
|
|
||||||
Idle.add(() => {
|
|
||||||
target.refresh.begin();
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
namespace StudySystemClient.Request {
|
|
||||||
public class Request {
|
|
||||||
private Der.Datum datum;
|
|
||||||
|
|
||||||
public Request(uint16 transaction_id, Body body) {
|
|
||||||
datum = new Der.Sequence(
|
|
||||||
{ new Der.Integer(transaction_id), body.datum });
|
|
||||||
}
|
|
||||||
|
|
||||||
public uint8[] encode() {
|
|
||||||
return datum.encode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract class Body {
|
|
||||||
protected enum Tag {
|
|
||||||
PING = 0,
|
|
||||||
LIST_ACTIVITIES = 1,
|
|
||||||
LOG_SESSION = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
internal Der.Datum datum;
|
|
||||||
|
|
||||||
protected Body(Tag tag, Der.Datum datum) {
|
|
||||||
this.datum = new Der.Choice(tag, datum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Ping : Body {
|
|
||||||
public Ping() {
|
|
||||||
base(Tag.PING, new Der.Null());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class ListActivities : Body {
|
|
||||||
public ListActivities() {
|
|
||||||
base(Tag.LIST_ACTIVITIES, new Der.Null());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class LogSession : Body {
|
|
||||||
public LogSession(string subject, ActivityType type,
|
|
||||||
int64 timestamp, int minutes)
|
|
||||||
{
|
|
||||||
var fields = new Der.Datum[] {
|
|
||||||
new Der.Utf8String(subject),
|
|
||||||
new Der.Enumerated((int)type),
|
|
||||||
new Der.Integer(timestamp),
|
|
||||||
new Der.Integer(minutes),
|
|
||||||
};
|
|
||||||
base(Tag.LOG_SESSION, new Der.Sequence(fields));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
namespace StudySystemClient.Response {
|
|
||||||
public class Response {
|
|
||||||
public int transaction_id { get; private set; }
|
|
||||||
public Body body { get; private set; }
|
|
||||||
|
|
||||||
public Response.from_bytes(uint8[] bytes) throws DecodeError {
|
|
||||||
Der.Sequence sequence;
|
|
||||||
try {
|
|
||||||
sequence = Der.decode(bytes) as Der.Sequence;
|
|
||||||
} catch (Der.DecodeError e) {
|
|
||||||
throw new DecodeError.INVALID_RESPONSE(
|
|
||||||
"Response was not valid DER: " + e.message);
|
|
||||||
}
|
|
||||||
if (sequence == null) {
|
|
||||||
throw new DecodeError.INVALID_RESPONSE(
|
|
||||||
"Response was not a SEQUENCE");
|
|
||||||
}
|
|
||||||
if (sequence.value.length < 2) {
|
|
||||||
throw new DecodeError.INVALID_RESPONSE(
|
|
||||||
"Too few fields in Response: %u (expected 2)",
|
|
||||||
sequence.value.length);
|
|
||||||
}
|
|
||||||
var id_datum = sequence.value[0] as Der.Integer;
|
|
||||||
if (id_datum == null) {
|
|
||||||
throw new DecodeError.INVALID_RESPONSE(
|
|
||||||
"Response transactionId was not an INTEGER");
|
|
||||||
}
|
|
||||||
transaction_id = (int)id_datum.value;
|
|
||||||
body = Body.from_datum(sequence.value[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public errordomain DecodeError {
|
|
||||||
INVALID_BODY,
|
|
||||||
INVALID_RESPONSE,
|
|
||||||
NOT_IMPLEMENTED,
|
|
||||||
}
|
|
||||||
|
|
||||||
public abstract class Body {
|
|
||||||
protected enum Tag {
|
|
||||||
ERROR = 0,
|
|
||||||
ACK = 1,
|
|
||||||
ACTIVITIES = 2,
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static Body from_datum(Der.Datum datum) throws DecodeError {
|
|
||||||
var choice = datum as Der.Choice;
|
|
||||||
if (choice == null) {
|
|
||||||
throw new DecodeError.INVALID_BODY(
|
|
||||||
"ResponseBody was not a CHOICE");
|
|
||||||
}
|
|
||||||
switch (choice.id) {
|
|
||||||
case Tag.ERROR:
|
|
||||||
return new Error.from_datum(choice.value);
|
|
||||||
case Tag.ACK:
|
|
||||||
return new Ack.from_datum(choice.value);
|
|
||||||
case Tag.ACTIVITIES:
|
|
||||||
return new Activities.from_datum(choice.value);
|
|
||||||
default:
|
|
||||||
throw new DecodeError.INVALID_BODY(
|
|
||||||
"Invalid ResponseBody tag");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Error : Body {
|
|
||||||
public enum Value {
|
|
||||||
INVALID_REQUEST = 0,
|
|
||||||
INVALID_ARGUMENTS = 1,
|
|
||||||
SERVER_ERROR = 2,
|
|
||||||
|
|
||||||
COUNT;
|
|
||||||
|
|
||||||
public string to_string() {
|
|
||||||
switch (this) {
|
|
||||||
case INVALID_REQUEST:
|
|
||||||
return "Invalid request";
|
|
||||||
case INVALID_ARGUMENTS:
|
|
||||||
return "Invalid arguments";
|
|
||||||
case SERVER_ERROR:
|
|
||||||
return "Server error";
|
|
||||||
default:
|
|
||||||
return "Unknown error";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public Value value { get; private set; }
|
|
||||||
|
|
||||||
internal Error.from_datum(Der.Datum datum) throws DecodeError {
|
|
||||||
var enumerated = datum as Der.Enumerated;
|
|
||||||
if (enumerated == null) {
|
|
||||||
throw new DecodeError.INVALID_BODY(
|
|
||||||
"Error was not an ENUMERATED");
|
|
||||||
}
|
|
||||||
if (enumerated.value < 0 || enumerated.value >= Value.COUNT) {
|
|
||||||
throw new DecodeError.INVALID_BODY(
|
|
||||||
"Error type was not in range 0..$(Value.COUNT)");
|
|
||||||
}
|
|
||||||
value = (Value)enumerated.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Ack : Body {
|
|
||||||
internal Ack.from_datum(Der.Datum datum) throws DecodeError {
|
|
||||||
var @null = datum as Der.Null;
|
|
||||||
if (@null == null) {
|
|
||||||
throw new DecodeError.INVALID_BODY("Ack was not NULL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Activities : Body {
|
|
||||||
public Array<Activity> value { get; private set; }
|
|
||||||
|
|
||||||
internal Activities.from_datum(Der.Datum datum) throws DecodeError {
|
|
||||||
value = new Array<Activity>();
|
|
||||||
if (datum is Der.Sequence) {
|
|
||||||
foreach (var activity_datum in datum.value)
|
|
||||||
value.append_val(activity_from_datum(activity_datum));
|
|
||||||
} else {
|
|
||||||
throw new DecodeError.INVALID_BODY(
|
|
||||||
"Activities was not a SEQUENCE");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Activity activity_from_datum(Der.Datum datum)
|
|
||||||
throws DecodeError {
|
|
||||||
if (datum is Der.Sequence) {
|
|
||||||
var fields = datum.value;
|
|
||||||
if (fields.length < 3) {
|
|
||||||
throw new DecodeError.INVALID_BODY(
|
|
||||||
"Too few fields in Activity: %u (expected 3)",
|
|
||||||
fields.length);
|
|
||||||
}
|
|
||||||
var subject = get_string("Activity.subject", fields[0]);
|
|
||||||
var activity_type
|
|
||||||
= get_activity_type("Activity.type", fields[1]);
|
|
||||||
var priority_int = get_int("Activity.priority", fields[2]);
|
|
||||||
var priority = (double)priority_int / 100.0;
|
|
||||||
return { subject, activity_type, priority };
|
|
||||||
} else {
|
|
||||||
throw new DecodeError.INVALID_BODY(
|
|
||||||
"Activity was not a SEQUENCE");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int get_int(string name, Der.Datum datum)
|
|
||||||
throws DecodeError {
|
|
||||||
if (datum is Der.Integer)
|
|
||||||
return (int)datum.value;
|
|
||||||
throw new DecodeError.INVALID_BODY(@"$name was not an INTEGER");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string get_string(string name, Der.Datum datum)
|
|
||||||
throws DecodeError {
|
|
||||||
if (datum is Der.Utf8String)
|
|
||||||
return datum.value;
|
|
||||||
throw new DecodeError.INVALID_BODY(
|
|
||||||
@"$name was not a UTF8String");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ActivityType get_activity_type(
|
|
||||||
string name, Der.Datum datum) throws DecodeError {
|
|
||||||
if (datum is Der.Enumerated) {
|
|
||||||
var value = datum.value;
|
|
||||||
if (0 <= value < ActivityType.COUNT)
|
|
||||||
return (ActivityType)value;
|
|
||||||
throw new DecodeError.INVALID_BODY(
|
|
||||||
"Invalid value for ActivityType: %lld", value);
|
|
||||||
}
|
|
||||||
throw new DecodeError.INVALID_BODY(
|
|
||||||
@"$name was not an ENUMERATED");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
namespace StudySystemClient {
|
|
||||||
public class SessionManager {
|
|
||||||
public delegate void ReceiveCallback(owned uint8[] msg);
|
|
||||||
|
|
||||||
private const uint INIT_RECONNECT_WAIT_MS = 500;
|
|
||||||
private const uint MAX_RECONNECT_WAIT_MS = 30000;
|
|
||||||
private const double RECONNECT_BACKOFF = 1.6;
|
|
||||||
|
|
||||||
private SessionFactory session_factory;
|
|
||||||
private ReceiveCallback receive_callback;
|
|
||||||
private Connection.StatusCallback status_callback;
|
|
||||||
private Session? session;
|
|
||||||
private AsyncQueue<OutgoingMessage> queue;
|
|
||||||
private uint reconnect_wait_ms;
|
|
||||||
|
|
||||||
public SessionManager(
|
|
||||||
SessionFactory session_factory,
|
|
||||||
owned ReceiveCallback receive_callback,
|
|
||||||
owned Connection.StatusCallback status_callback) {
|
|
||||||
this.session_factory = session_factory;
|
|
||||||
this.receive_callback = (owned) receive_callback;
|
|
||||||
this.status_callback = (owned) status_callback;
|
|
||||||
this.session = null;
|
|
||||||
queue = new AsyncQueue<OutgoingMessage>();
|
|
||||||
reconnect_wait_ms = INIT_RECONNECT_WAIT_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void send(uint8[] msg) {
|
|
||||||
queue.push(new OutgoingMessage(msg));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void task() {
|
|
||||||
if (session != null) {
|
|
||||||
var failed_msg = session.task(queue);
|
|
||||||
if (failed_msg != null)
|
|
||||||
handle_failed_msg(failed_msg);
|
|
||||||
} else {
|
|
||||||
try_start_session();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handle_failed_msg(OutgoingMessage msg) {
|
|
||||||
msg.has_failed();
|
|
||||||
if (msg.should_retry())
|
|
||||||
queue.push(msg);
|
|
||||||
session = null;
|
|
||||||
status_callback(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void try_start_session() {
|
|
||||||
try {
|
|
||||||
session = session_factory.start_session();
|
|
||||||
session.received.connect((msg) => receive_callback(msg));
|
|
||||||
reconnect_wait_ms = INIT_RECONNECT_WAIT_MS;
|
|
||||||
status_callback(true);
|
|
||||||
} catch (Error _) {
|
|
||||||
status_callback(false);
|
|
||||||
Thread.usleep(1000 * reconnect_wait_ms);
|
|
||||||
update_reconnect_wait();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void update_reconnect_wait() {
|
|
||||||
var new_wait = RECONNECT_BACKOFF * reconnect_wait_ms;
|
|
||||||
if (new_wait < MAX_RECONNECT_WAIT_MS)
|
|
||||||
reconnect_wait_ms = (uint)new_wait;
|
|
||||||
else
|
|
||||||
reconnect_wait_ms = MAX_RECONNECT_WAIT_MS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SessionFactory {
|
|
||||||
private const string CA_FILENAME = "/ca.pem";
|
|
||||||
private const string CERT_FILENAME = "/client.pem";
|
|
||||||
private const uint TIMEOUT_S = 2;
|
|
||||||
|
|
||||||
private InetSocketAddress host;
|
|
||||||
private TlsCertificate cert;
|
|
||||||
private TlsDatabase ca_db;
|
|
||||||
|
|
||||||
public SessionFactory(InetAddress host_addr, uint16 host_port,
|
|
||||||
string cert_dir) throws Error {
|
|
||||||
host = new InetSocketAddress(host_addr, host_port);
|
|
||||||
var cert_path = cert_dir + CERT_FILENAME;
|
|
||||||
cert = new TlsCertificate.from_file(cert_path);
|
|
||||||
var ca_path = cert_dir + CA_FILENAME;
|
|
||||||
var db_type = TlsBackend.get_default().get_file_database_type();
|
|
||||||
ca_db = Object.new(db_type, "anchors", ca_path) as TlsDatabase;
|
|
||||||
}
|
|
||||||
|
|
||||||
internal Session start_session() throws Error {
|
|
||||||
var plain_client = new SocketClient();
|
|
||||||
plain_client.set_timeout(TIMEOUT_S);
|
|
||||||
var plain_connection = plain_client.connect(host);
|
|
||||||
var connection = TlsClientConnection.new(plain_connection, host);
|
|
||||||
connection.set_database(ca_db);
|
|
||||||
connection.set_certificate(cert);
|
|
||||||
connection.handshake();
|
|
||||||
return new Session(connection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class Session {
|
|
||||||
public signal void received(uint8[] msg);
|
|
||||||
|
|
||||||
private const uint MAX_BATCH_SIZE = 10;
|
|
||||||
private const uint MAX_MSG_LEN = 1024;
|
|
||||||
|
|
||||||
private TlsClientConnection connection;
|
|
||||||
|
|
||||||
public Session(TlsClientConnection connection) {
|
|
||||||
this.connection = connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
public OutgoingMessage? task(AsyncQueue<OutgoingMessage> queue) {
|
|
||||||
for (int i = 0; i < MAX_BATCH_SIZE; ++i) {
|
|
||||||
if (queue.length() == 0)
|
|
||||||
break;
|
|
||||||
var msg = queue.pop();
|
|
||||||
var success = true;
|
|
||||||
success &= send(msg);
|
|
||||||
success &= receive();
|
|
||||||
if (!success)
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool send(OutgoingMessage msg) {
|
|
||||||
try {
|
|
||||||
size_t written;
|
|
||||||
connection.output_stream.write_all(msg.content, out written);
|
|
||||||
return true;
|
|
||||||
} catch (IOError _) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool receive() {
|
|
||||||
try {
|
|
||||||
var buffer = new uint8[MAX_MSG_LEN];
|
|
||||||
var len = connection.input_stream.read(buffer);
|
|
||||||
if (len <= 0)
|
|
||||||
return false;
|
|
||||||
received(buffer[0:len]);
|
|
||||||
return true;
|
|
||||||
} catch (IOError _) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class OutgoingMessage {
|
|
||||||
public uint8[] content { get; private set; }
|
|
||||||
|
|
||||||
private const uint MAX_FAIL_COUNT = 4;
|
|
||||||
|
|
||||||
private uint fail_count;
|
|
||||||
|
|
||||||
public OutgoingMessage(owned uint8[] content) {
|
|
||||||
this.content = (owned)content;
|
|
||||||
fail_count = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void has_failed() {
|
|
||||||
++fail_count;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool should_retry() {
|
|
||||||
return fail_count < MAX_FAIL_COUNT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
.card-container {
|
|
||||||
background-color: color-mix(in oklab, @theme_base_color,
|
|
||||||
@theme_bg_color 60%);
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
|
|
||||||
background-color: @theme_bg_color;
|
|
||||||
padding: 12px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-subject {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.activity-priority {
|
|
||||||
color: color-mix(in oklab, @theme_fg_color 60%, @theme_bg_color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* The visual center (i.e. the center of the clock) of the
|
|
||||||
* "appointment-new-symbolic" icon is slightly displaced from the
|
|
||||||
* center of the actual image, so tweak it here.
|
|
||||||
*/
|
|
||||||
.log-session-button image {
|
|
||||||
margin-top: -2px;
|
|
||||||
margin-left: 3px;
|
|
||||||
}
|
|
||||||
@@ -98,32 +98,6 @@ static void test_utf8string_value(string expected, Datum datum) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static void test_decode_enumerated(uint8[] bytes, int64 expected) {
|
|
||||||
Datum datum;
|
|
||||||
try {
|
|
||||||
datum = decode(bytes);
|
|
||||||
} catch (DecodeError err) {
|
|
||||||
Test.message("Decoding failed: %s", err.message);
|
|
||||||
Test.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
test_enumerated_value(expected, datum);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_enumerated_value(int64 expected, Datum datum) {
|
|
||||||
var enumerated = datum as Enumerated;
|
|
||||||
if (enumerated == null) {
|
|
||||||
Test.message("Bytes were not decoded as an ENUMERATED");
|
|
||||||
Test.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (enumerated.value != expected) {
|
|
||||||
Test.message(@"Expected $expected got $(enumerated.value)");
|
|
||||||
Test.fail();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void main(string[] args) {
|
void main(string[] args) {
|
||||||
Test.init(ref args);
|
Test.init(ref args);
|
||||||
|
|
||||||
@@ -210,10 +184,6 @@ void main(string[] args) {
|
|||||||
test_encode(new Der.Null(), { 0x05, 0x00 });
|
test_encode(new Der.Null(), { 0x05, 0x00 });
|
||||||
});
|
});
|
||||||
|
|
||||||
Test.add_func("/encode/enumerated/42", () => {
|
|
||||||
test_encode(new Enumerated(42), {0x0a, 0x01, 0x2a});
|
|
||||||
});
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Decoding
|
* Decoding
|
||||||
*/
|
*/
|
||||||
@@ -340,9 +310,6 @@ void main(string[] args) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Test.add_func("/decode/enumerated/42", () => {
|
|
||||||
test_decode_enumerated({0x0a, 0x01, 0x2a}, 42);
|
|
||||||
});
|
|
||||||
|
|
||||||
Test.run();
|
Test.run();
|
||||||
}
|
}
|
||||||
|
|||||||
1
server/.gitignore
vendored
1
server/.gitignore
vendored
@@ -1,4 +1,3 @@
|
|||||||
_build/*
|
_build/*
|
||||||
*.beam
|
*.beam
|
||||||
src/StudySystemProtocol.erl
|
src/StudySystemProtocol.erl
|
||||||
include/*
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
StudySystemProtocol DEFINITIONS EXPLICIT TAGS ::= BEGIN
|
|
||||||
|
|
||||||
ActivityType ::= ENUMERATED {
|
|
||||||
reading(0),
|
|
||||||
exercises(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
Session ::= SEQUENCE {
|
|
||||||
subject UTF8String,
|
|
||||||
type ActivityType,
|
|
||||||
timestamp INTEGER,
|
|
||||||
minutes INTEGER
|
|
||||||
}
|
|
||||||
|
|
||||||
RequestBody ::= CHOICE {
|
|
||||||
ping [0] NULL,
|
|
||||||
listActivities [1] NULL,
|
|
||||||
logSession [2] Session
|
|
||||||
}
|
|
||||||
|
|
||||||
Request ::= SEQUENCE {
|
|
||||||
transactionId INTEGER,
|
|
||||||
body RequestBody
|
|
||||||
}
|
|
||||||
|
|
||||||
Activity ::= SEQUENCE {
|
|
||||||
subject UTF8String,
|
|
||||||
type ActivityType,
|
|
||||||
priority INTEGER
|
|
||||||
}
|
|
||||||
|
|
||||||
Error ::= ENUMERATED {
|
|
||||||
invalidRequest(0),
|
|
||||||
invalidArguments(1),
|
|
||||||
serverError(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
ResponseBody ::= CHOICE {
|
|
||||||
error [0] Error,
|
|
||||||
ack [1] NULL,
|
|
||||||
activities [2] SEQUENCE OF Activity
|
|
||||||
}
|
|
||||||
|
|
||||||
Response ::= SEQUENCE {
|
|
||||||
transactionId INTEGER,
|
|
||||||
body ResponseBody
|
|
||||||
}
|
|
||||||
|
|
||||||
END
|
|
||||||
@@ -11,9 +11,9 @@ start_link(Port, CertDir) ->
|
|||||||
supervisor:start_link({local, ?MODULE}, ?MODULE, [Port, CertDir]).
|
supervisor:start_link({local, ?MODULE}, ?MODULE, [Port, CertDir]).
|
||||||
|
|
||||||
init([Port, CertDir]) ->
|
init([Port, CertDir]) ->
|
||||||
SupFlags = #{strategy => one_for_one,
|
SupFlags = #{stragegy => one_for_one,
|
||||||
intensity => 5,
|
intensity => 1,
|
||||||
period => 10},
|
period => 5},
|
||||||
ChildSpecs = [#{id => session_sup,
|
ChildSpecs = [#{id => session_sup,
|
||||||
start => {session_sup, start_link, []},
|
start => {session_sup, start_link, []},
|
||||||
restart => permanent,
|
restart => permanent,
|
||||||
|
|||||||
@@ -12,10 +12,8 @@ start_link(Socket) ->
|
|||||||
gen_server:start_link(?MODULE, Socket, []).
|
gen_server:start_link(?MODULE, Socket, []).
|
||||||
|
|
||||||
init(Socket) ->
|
init(Socket) ->
|
||||||
ok = ssl:controlling_process(Socket, self()),
|
ssl:setopts(Socket, [{active, true}]),
|
||||||
ok = ssl:setopts(Socket, [{active, true}]),
|
{ok, #{socket => Socket}}.
|
||||||
process_flag(trap_exit, true),
|
|
||||||
{ok, #{socket => Socket, transactions => #{}}}.
|
|
||||||
|
|
||||||
handle_call(_Request, _From, State) ->
|
handle_call(_Request, _From, State) ->
|
||||||
{reply, ok, State}.
|
{reply, ok, State}.
|
||||||
@@ -23,30 +21,17 @@ handle_call(_Request, _From, State) ->
|
|||||||
handle_cast(_Msg, State) ->
|
handle_cast(_Msg, State) ->
|
||||||
{noreply, State}.
|
{noreply, State}.
|
||||||
|
|
||||||
handle_info({ssl, Socket, Data}, State = #{transactions := Transactions}) ->
|
handle_info({ssl, Socket, Data}, State) ->
|
||||||
case 'StudySystemProtocol':decode('Request', Data) of
|
case 'StudySystemProtocol':decode('Request', Data) of
|
||||||
{ok, {'Request', TransactionId, RequestBody}} ->
|
{ok, {foo, _}} ->
|
||||||
Pid = spawn_link(fun() -> handle_request(RequestBody) end),
|
{ok, Encoded}
|
||||||
NewTransactions = maps:put(Pid, TransactionId, Transactions),
|
= 'StudySystemProtocol':encode('Response', {msg, "Foo"}),
|
||||||
{noreply, State#{transactions := NewTransactions}};
|
ssl:send(Socket, Encoded);
|
||||||
{error, {asn1, _Reason}} ->
|
Result ->
|
||||||
send(Socket, -1, {error, invalidRequest}),
|
io:format("Invalid message: ~p~n", [Result]),
|
||||||
{noreply, State}
|
ok
|
||||||
end;
|
end,
|
||||||
handle_info({'EXIT', Pid, Reason},
|
{noreply, State};
|
||||||
State = #{socket := Socket, transactions := Transactions})
|
|
||||||
when is_map_key(Pid, Transactions) ->
|
|
||||||
TransactionId = maps:get(Pid, Transactions),
|
|
||||||
Response = case Reason of
|
|
||||||
{response, Value} -> Value;
|
|
||||||
_ -> {error, serverError}
|
|
||||||
end,
|
|
||||||
send(Socket, TransactionId, Response),
|
|
||||||
{noreply, State#{transactions := maps:remove(Pid, Transactions)}};
|
|
||||||
handle_info({ssl_closed, _Socket}, State) ->
|
|
||||||
{stop, normal, State};
|
|
||||||
handle_info({ssl_error, _Socket, _Reason}, State) ->
|
|
||||||
{stop, normal, State};
|
|
||||||
handle_info(_Info, State) ->
|
handle_info(_Info, State) ->
|
||||||
{noreply, State}.
|
{noreply, State}.
|
||||||
|
|
||||||
@@ -55,30 +40,3 @@ terminate(_Reason, #{socket := Socket}) ->
|
|||||||
|
|
||||||
code_change(_OldVsn, State, _Extra) ->
|
code_change(_OldVsn, State, _Extra) ->
|
||||||
{ok, State}.
|
{ok, State}.
|
||||||
|
|
||||||
handle_request(Request) ->
|
|
||||||
timer:kill_after(500),
|
|
||||||
exit({response, map_request(Request)}).
|
|
||||||
|
|
||||||
map_request({ping, 'NULL'}) ->
|
|
||||||
{ack, 'NULL'};
|
|
||||||
map_request({listActivities, 'NULL'}) ->
|
|
||||||
{activities, Activities} = subject_router:get_activities(),
|
|
||||||
{activities,
|
|
||||||
[{'Activity', Subject, Type, round(Priority * 100)}
|
|
||||||
|| {Subject, Type, Priority} <- Activities]};
|
|
||||||
map_request({logSession, {'Session', Subject, Type, Timestamp, Minutes}}) ->
|
|
||||||
Session = {unicode:characters_to_list(Subject),
|
|
||||||
Type, Timestamp, Minutes},
|
|
||||||
case subject_router:log_session(Session) of
|
|
||||||
ok -> {ack, 'NULL'};
|
|
||||||
{error, _Error} -> {error, invalidArguments}
|
|
||||||
end;
|
|
||||||
map_request(_) ->
|
|
||||||
{error, invalidArguments}.
|
|
||||||
|
|
||||||
send(Socket, TransactionId, Response) ->
|
|
||||||
{ok, Encoded} = 'StudySystemProtocol':encode(
|
|
||||||
'Response',
|
|
||||||
{'Response', TransactionId, Response}),
|
|
||||||
ok = ssl:send(Socket, Encoded).
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ start_link() ->
|
|||||||
|
|
||||||
init([]) ->
|
init([]) ->
|
||||||
SupFlags = #{strategy => simple_one_for_one,
|
SupFlags = #{strategy => simple_one_for_one,
|
||||||
intensity => 5,
|
intensity => 10,
|
||||||
period => 10},
|
period => 1},
|
||||||
ChildSpec = #{id => session_server,
|
ChildSpec = #{id => session_server,
|
||||||
start => {session_server, start_link, []},
|
start => {session_server, start_link, []},
|
||||||
restart => temporary,
|
restart => temporary,
|
||||||
|
|||||||
@@ -9,11 +9,7 @@
|
|||||||
start(_StartType, _StartArgs) ->
|
start(_StartType, _StartArgs) ->
|
||||||
Port = application:get_env(study_system_server, port, 12888),
|
Port = application:get_env(study_system_server, port, 12888),
|
||||||
{ok, CertDir} = application:get_env(study_system_server, cert_dir),
|
{ok, CertDir} = application:get_env(study_system_server, cert_dir),
|
||||||
{ok, _Pid} = pg:start(study_system_server),
|
proto_sup:start_link(Port, CertDir).
|
||||||
study_system_server_sup:start_link(Port, CertDir),
|
|
||||||
subject_sup:start_subject("Cybernetics"),
|
|
||||||
subject_sup:start_subject("Physics"),
|
|
||||||
subject_sup:start_subject("Linguistics").
|
|
||||||
|
|
||||||
stop(_State) ->
|
stop(_State) ->
|
||||||
ok.
|
ok.
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
% Copyright (c) Camden Dixie O'Brien
|
|
||||||
% SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
-module(study_system_server_sup).
|
|
||||||
-behaviour(supervisor).
|
|
||||||
|
|
||||||
-export([start_link/2]).
|
|
||||||
-export([init/1]).
|
|
||||||
|
|
||||||
start_link(Port, CertDir) ->
|
|
||||||
supervisor:start_link({local, ?MODULE}, ?MODULE, [Port, CertDir]).
|
|
||||||
|
|
||||||
init([Port, CertDir]) ->
|
|
||||||
SupFlags = #{strategy => one_for_one,
|
|
||||||
intensity => 5,
|
|
||||||
period => 10},
|
|
||||||
ChildSpecs = [#{id => subject_router,
|
|
||||||
start => {subject_router, start_link, []},
|
|
||||||
restart => permanent,
|
|
||||||
shutdown => 5000,
|
|
||||||
type => worker,
|
|
||||||
modules => [subject_router]},
|
|
||||||
#{id => subject_sup,
|
|
||||||
start => {subject_sup, start_link, []},
|
|
||||||
restart => permanent,
|
|
||||||
shutdown => 5000,
|
|
||||||
type => supervisor,
|
|
||||||
modules => [subject_sup]},
|
|
||||||
#{id => proto_sup,
|
|
||||||
start => {proto_sup, start_link, [Port, CertDir]},
|
|
||||||
restart => permanent,
|
|
||||||
shutdown => 5000,
|
|
||||||
type => supervisor,
|
|
||||||
modules => [proto_sup]}],
|
|
||||||
{ok, {SupFlags, ChildSpecs}}.
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
% Copyright (c) Camden Dixie O'Brien
|
|
||||||
% SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
-module(subject_router).
|
|
||||||
-behaviour(gen_server).
|
|
||||||
|
|
||||||
-export([start_link/0, log_session/1, get_activities/0]).
|
|
||||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
|
||||||
terminate/2, code_change/3]).
|
|
||||||
|
|
||||||
start_link() ->
|
|
||||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []).
|
|
||||||
|
|
||||||
log_session(Session) ->
|
|
||||||
gen_server:call(?MODULE, {log_session, Session}).
|
|
||||||
|
|
||||||
get_activities() ->
|
|
||||||
gen_server:call(?MODULE, get_activities).
|
|
||||||
|
|
||||||
init([]) ->
|
|
||||||
{MonitorRef, Pids} = pg:monitor(study_system_server, subject_servers),
|
|
||||||
SubjectTable = ets:new(subject_table, [private]),
|
|
||||||
register_servers(SubjectTable, Pids),
|
|
||||||
{ok, #{monitor_ref => MonitorRef,
|
|
||||||
subject_table => SubjectTable}}.
|
|
||||||
|
|
||||||
handle_call({log_session, {Subject, Type, Timestamp, Minutes}},
|
|
||||||
_From, State = #{subject_table := SubjectTable}) ->
|
|
||||||
case ets:lookup(SubjectTable, Subject) of
|
|
||||||
[{Subject, Pid}] ->
|
|
||||||
{reply,
|
|
||||||
subject_server:log_session(Pid, Type, Timestamp, Minutes),
|
|
||||||
State};
|
|
||||||
[] ->
|
|
||||||
{reply, {error, invalid_subject}, State}
|
|
||||||
end;
|
|
||||||
handle_call(get_activities, _From,
|
|
||||||
State = #{subject_table := SubjectTable}) ->
|
|
||||||
Pids = lists:flatten(ets:match(SubjectTable, {'_', '$1'})),
|
|
||||||
Activities = lists:flatmap(
|
|
||||||
fun(Pid) -> subject_server:get_activities(Pid) end,
|
|
||||||
Pids),
|
|
||||||
{reply, {activities, Activities}, State};
|
|
||||||
handle_call(_Request, _From, State) ->
|
|
||||||
{reply, ok, State}.
|
|
||||||
|
|
||||||
handle_cast(_Msg, State) ->
|
|
||||||
{noreply, State}.
|
|
||||||
|
|
||||||
handle_info({Ref, join, subject_servers, Pids},
|
|
||||||
State = #{monitor_ref := MonitorRef,
|
|
||||||
subject_table := SubjectTable})
|
|
||||||
when Ref =:= MonitorRef ->
|
|
||||||
register_servers(SubjectTable, Pids),
|
|
||||||
{noreply, State};
|
|
||||||
handle_info({Ref, leave, subject_servers, Pids},
|
|
||||||
State = #{monitor_ref := MonitorRef,
|
|
||||||
subject_table := SubjectTable})
|
|
||||||
when Ref =:= MonitorRef ->
|
|
||||||
deregister_servers(SubjectTable, Pids),
|
|
||||||
{noreply, State};
|
|
||||||
handle_info(_Info, State) ->
|
|
||||||
{noreply, State}.
|
|
||||||
|
|
||||||
terminate(_Reason, _State) ->
|
|
||||||
ok.
|
|
||||||
|
|
||||||
code_change(_OldVsn, State, _Extra) ->
|
|
||||||
{ok, State}.
|
|
||||||
|
|
||||||
register_servers(SubjectTable, Pids) ->
|
|
||||||
[register_server(SubjectTable, Pid) || Pid <- Pids].
|
|
||||||
|
|
||||||
register_server(SubjectTable, Pid) ->
|
|
||||||
try
|
|
||||||
{subject, Subject} = subject_server:get_subject(Pid),
|
|
||||||
ets:insert(SubjectTable, {Subject, Pid})
|
|
||||||
catch
|
|
||||||
_:_ -> ok
|
|
||||||
end.
|
|
||||||
|
|
||||||
deregister_servers(SubjectTable, Pids) ->
|
|
||||||
[deregister_server(SubjectTable, Pid) || Pid <- Pids].
|
|
||||||
|
|
||||||
deregister_server(SubjectTable, Pid) ->
|
|
||||||
ets:match_delete(SubjectTable, {'_', Pid}).
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
% Copyright (c) Camden Dixie O'Brien
|
|
||||||
% SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
-module(subject_server).
|
|
||||||
-behaviour(gen_server).
|
|
||||||
|
|
||||||
-export([start_link/1, get_subject/1, get_activities/1, log_session/4]).
|
|
||||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
|
||||||
terminate/2, code_change/3]).
|
|
||||||
|
|
||||||
start_link(Subject) ->
|
|
||||||
gen_server:start_link(?MODULE, Subject, []).
|
|
||||||
|
|
||||||
get_subject(Pid) ->
|
|
||||||
gen_server:call(Pid, get_subject).
|
|
||||||
|
|
||||||
get_activities(Pid) ->
|
|
||||||
gen_server:call(Pid, get_activities).
|
|
||||||
|
|
||||||
log_session(Pid, Type, Timestamp, Minutes) ->
|
|
||||||
gen_server:call(Pid, {log_session, {Type, Timestamp, Minutes}}).
|
|
||||||
|
|
||||||
init(Subject) ->
|
|
||||||
pg:join(study_system_server, subject_servers, self()),
|
|
||||||
{ok, #{subject => Subject,
|
|
||||||
priorities => #{reading => rand:uniform(),
|
|
||||||
exercises => rand:uniform()}}}.
|
|
||||||
|
|
||||||
handle_call(get_subject, _From, State = #{subject := Subject}) ->
|
|
||||||
{reply, {subject, Subject}, State};
|
|
||||||
handle_call(get_activities, _From,
|
|
||||||
State = #{subject := Subject,
|
|
||||||
priorities := #{reading := ReadingPriority,
|
|
||||||
exercises := ExercisesPriority}}) ->
|
|
||||||
Reading = {Subject, reading, ReadingPriority},
|
|
||||||
Exercises = {Subject, exercises, ExercisesPriority},
|
|
||||||
{reply, [Reading, Exercises], State};
|
|
||||||
handle_call({log_session, {Type, Timestamp, Minutes}}, _From,
|
|
||||||
State = #{subject := Subject, priorities := Priorities}) ->
|
|
||||||
case Priorities of
|
|
||||||
#{Type := Priority} ->
|
|
||||||
UpdatedPriorities = Priorities#{Type := Priority * 0.667},
|
|
||||||
{reply, ok, State#{priorities := UpdatedPriorities}};
|
|
||||||
_ ->
|
|
||||||
{reply, {error, invalid_type}, State}
|
|
||||||
end;
|
|
||||||
handle_call(_Request, _From, State) ->
|
|
||||||
{reply, ok, State}.
|
|
||||||
|
|
||||||
handle_cast(_Msg, State) ->
|
|
||||||
{noreply, State}.
|
|
||||||
|
|
||||||
handle_info(_Info, State) ->
|
|
||||||
{noreply, State}.
|
|
||||||
|
|
||||||
terminate(_Reason, _State) ->
|
|
||||||
ok.
|
|
||||||
|
|
||||||
code_change(_OldVsn, State, _Extra) ->
|
|
||||||
{ok, State}.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
% Copyright (c) Camden Dixie O'Brien
|
|
||||||
% SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
-module(subject_sup).
|
|
||||||
-behaviour(supervisor).
|
|
||||||
|
|
||||||
-export([start_link/0, init/1, start_subject/1]).
|
|
||||||
|
|
||||||
start_link() ->
|
|
||||||
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
|
|
||||||
|
|
||||||
init([]) ->
|
|
||||||
SupFlags = #{strategy => simple_one_for_one,
|
|
||||||
intensity => 5,
|
|
||||||
period => 10},
|
|
||||||
ChildSpec = #{id => subject_server,
|
|
||||||
start => {subject_server, start_link, []},
|
|
||||||
restart => permanent,
|
|
||||||
shutdown => 5000,
|
|
||||||
type => worker,
|
|
||||||
modules => [subject_server]},
|
|
||||||
{ok, {SupFlags, [ChildSpec]}}.
|
|
||||||
|
|
||||||
start_subject(Subject) ->
|
|
||||||
supervisor:start_child(?MODULE, [Subject]).
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
|
||||||
terminate/2, code_change/3]).
|
terminate/2, code_change/3]).
|
||||||
|
|
||||||
-record(state, {socket}).
|
-record(state, {socket, acceptor}).
|
||||||
|
|
||||||
start_link(Port, CertDir) ->
|
start_link(Port, CertDir) ->
|
||||||
gen_server:start_link({local, ?MODULE}, ?MODULE, [Port, CertDir], []).
|
gen_server:start_link({local, ?MODULE}, ?MODULE, [Port, CertDir], []).
|
||||||
@@ -39,9 +39,10 @@ handle_info(accept, State = #state{socket = Socket}) ->
|
|||||||
end,
|
end,
|
||||||
{noreply, State};
|
{noreply, State};
|
||||||
handle_info({handshake, TlsSocket}, State) ->
|
handle_info({handshake, TlsSocket}, State) ->
|
||||||
case ssl:handshake(TlsSocket, 5000) of
|
case ssl:handshake(TlsSocket) of
|
||||||
{ok, ClientSocket} ->
|
{ok, ClientSocket} ->
|
||||||
{ok, _Pid} = session_sup:start_session(ClientSocket);
|
{ok, Pid} = session_sup:start_session(ClientSocket),
|
||||||
|
ok = ssl:controlling_process(ClientSocket, Pid);
|
||||||
{error, _Reason} ->
|
{error, _Reason} ->
|
||||||
ok
|
ok
|
||||||
end,
|
end,
|
||||||
|
|||||||
Reference in New Issue
Block a user