Compare commits

..

43 Commits

Author SHA1 Message Date
c8f2dbaadc Use COUNT member in enums 2025-03-03 11:17:52 +00:00
e81a47d83f Extract ActivitesView updating logic into own class 2025-03-03 10:42:26 +00:00
5697cf0652 Remove Card class and rename CardArea<T> to ListView<T> 2025-03-03 09:31:43 +00:00
b2f5288c4b Remove sorting from server
The client is doing it itself now anyway.
2025-03-03 01:06:43 +00:00
8123c5375d Regularly refresh activities and update cards in-place 2025-03-03 01:04:56 +00:00
3e99cc293a Create Refresher class (and IRefreshable interface) 2025-03-02 18:16:08 +00:00
e275fa01ed Add start() method to Periodic (and construct stopped) 2025-03-02 18:16:04 +00:00
487427cf0a Add connected property to Client 2025-03-02 17:05:16 +00:00
5912302043 Add connection indicator to client 2025-03-02 16:53:11 +00:00
7c26a9278f Transfer ownership of client socket in session_server:init/1 2025-03-02 16:53:11 +00:00
8a7032309f Replace deprecated CSS functions with standard ones 2025-03-02 16:53:11 +00:00
708343c37f Add connection_status signal to Client 2025-03-02 16:53:08 +00:00
10a7fe5c82 Remove refreshing indicator from ActivitiesView
I've decided I'm going to add an indicator for when the client is
disconnected so the indicator would be pretty redundant (and it
requires a bunch of code to implement).
2025-03-02 15:14:43 +00:00
c2d81778a8 Extract Card and CardArea classes 2025-03-02 14:47:09 +00:00
94df48db7b Extract Periodic class from connection's Worker 2025-03-02 14:03:33 +00:00
76aca12fec Add priority to activity cards 2025-03-02 13:40:55 +00:00
5a6b535beb Animate refresh indicator show/hide 2025-03-02 12:54:27 +00:00
019bdf9ce6 Add refreshing indicator to ActivitiesView 2025-03-02 12:54:27 +00:00
17629f1db7 Reduce client-side timeout 2025-03-02 12:54:27 +00:00
0f0bd37cc8 Get activities from server and refresh on session log 2025-03-02 12:54:27 +00:00
bf876336f2 Handle listActivities on server 2025-03-02 12:54:27 +00:00
09f0648138 Actually log a session from client instead of pinging 2025-03-01 17:35:40 +00:00
d2a479f691 Create subject subsystem on server 2025-03-01 17:35:35 +00:00
68c55573de Remove subject IDs and improve request handling 2025-03-01 17:22:31 +00:00
9588e88b93 Support more PDUs and create Client class
The Client class provides a nice, high-level async API for
client-server communication.
2025-03-01 10:37:01 +00:00
38d6b2fa9b Remove 'Prioritized' from PDU names 2025-03-01 10:36:22 +00:00
a02ec90816 Implement transaction management in client 2025-03-01 10:36:22 +00:00
959be64cc1 Add transaction IDs to messages and handle on the server 2025-02-28 19:14:14 +00:00
c683f72e10 Mark Datum.type and Datum.content as protected instead of internal 2025-02-28 14:02:25 +00:00
cdbef62e70 Add some real messages to ASN.1 and handle pings on server 2025-02-28 12:46:03 +00:00
fe8b1ef977 Move SessionManager and related classes to own file 2025-02-28 12:44:55 +00:00
90a6eb3ab4 Support ENUMERATED in client DER library 2025-02-28 00:04:01 +00:00
38ef12fa02 Add popover for entering session length 2025-02-27 23:07:24 +00:00
5038a24539 Create skeleton of main activities view UI in client 2025-02-27 23:05:08 +00:00
6b53dd1526 Match results on ok in session_server 2025-02-25 23:07:20 +00:00
c8b9337da8 Remove unused acceptor field from tcp_server state 2025-02-25 23:06:14 +00:00
b33aeb8014 Make proto_sup strategy less strict 2025-02-25 23:06:14 +00:00
0238fbbd2a Add timeouts to handshake in tcp_server 2025-02-25 23:06:10 +00:00
236eed5505 Handle exit properly in session_server 2025-02-25 22:17:50 +00:00
727c0aedd6 Implement demo of DER comms 2025-02-25 22:17:50 +00:00
f5cb3b7166 Reduce message MAX_FAIL_COUNT 2025-02-25 22:00:50 +00:00
218c1e3644 Add NULL support to Client DER library 2025-02-25 21:58:59 +00:00
10560371ab Fix CHOICE DER support 2025-02-25 21:58:48 +00:00
32 changed files with 1655 additions and 335 deletions

View File

@@ -9,6 +9,14 @@ project(
add_project_arguments('-w', language: 'c')
gtk_dep = dependency('gtk4')
gnome = import('gnome')
resources = gnome.compile_resources(
'resources',
'resources.gresource.xml',
source_dir: '.',
c_name: 'resources'
)
subdir('src')
subdir('tests')

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<gresources>
<gresource prefix="/sh/wip/studysystemclient">
<file>styles.css</file>
</gresource>
</gresources>

View File

@@ -0,0 +1,140 @@
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;
}
}
}

25
client/src/activity.vala Normal file
View File

@@ -0,0 +1,25 @@
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";
}
}
}
}

View File

@@ -0,0 +1,122 @@
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;
}
}
}

68
client/src/client.vala Normal file
View File

@@ -0,0 +1,68 @@
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");
}
}
}
}

View File

@@ -1,221 +1,122 @@
namespace StudySystemClient {
public class Connection {
public signal void received(uint8[] msg);
public delegate void StatusCallback(bool connected);
public bool connected { get; private set; }
private StatusCallback status_callback;
private SessionManager session_manager;
private TransactionManager transaction_manager;
private Worker worker;
public Connection(string cert_dir) throws Error {
public Connection(string cert_dir,
owned StatusCallback status_callback)
throws Error {
var loopback = new InetAddress.loopback(SocketFamily.IPV6);
var session_factory
= new SessionFactory(loopback, 12888, cert_dir);
this.status_callback = (owned) status_callback;
session_manager = new SessionManager(
session_factory, (msg) => {
var msg_copy = new uint8[msg.length];
Memory.copy(msg_copy, msg, msg.length);
Idle.add(() => {
received(msg_copy);
return false;
}, GLib.Priority.DEFAULT_IDLE);
});
session_factory, (msg) => receive(msg),
(connected) => update_status(connected));
transaction_manager = new TransactionManager();
worker = new Worker(session_manager);
connected = true;
}
public void send(owned uint8[] msg) {
session_manager.send(msg);
public async Response.Body? send(Request.Body body) {
var transaction_id = transaction_manager.register(send.callback);
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 Worker {
private uint TASK_PERIOD_MS = 10;
private class Continuation {
private SourceFunc callback;
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 bool exit;
private Thread<void> thread;
public Worker(SessionManager session_manager) {
base(TASK_PERIOD_MS);
this.session_manager = session_manager;
exit = false;
thread = new Thread<void>("connection_worker", body);
start();
}
~Worker() {
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 = 10;
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;
protected override void task() {
session_manager.task();
}
}
}

View File

@@ -6,6 +6,7 @@ namespace StudySystemClient.Der {
}
private const uint BASE_HEADER_SIZE = 2;
private const uint8 MAX_INT64_BYTES = 8;
public static Datum decode(uint8[] bytes, out uint? size = null)
throws DecodeError {
@@ -48,7 +49,7 @@ namespace StudySystemClient.Der {
private static Datum decode_datum(uint8 type, uint8[] content)
throws DecodeError {
if ((type & 0xc0) == Choice.BASE_TYPE)
if ((type & ~Choice.ID_MASK) == Choice.BASE_TYPE)
return new Choice.from_content(type, content);
switch (type) {
case Boolean.TYPE:
@@ -59,6 +60,10 @@ namespace StudySystemClient.Der {
return new Utf8String.from_content(content);
case Sequence.TYPE:
return new Sequence.from_content(content);
case Null.TYPE:
return new Null.from_content(content);
case Enumerated.TYPE:
return new Enumerated.from_content(content);
default:
throw new DecodeError.UNKNOWN_TYPE("Unsupported type: %02x",
type);
@@ -66,8 +71,8 @@ namespace StudySystemClient.Der {
}
public abstract class Datum {
internal uint8 type;
internal uint8[] content;
protected uint8 type;
protected uint8[] content;
public uint8[] encode() {
var buffer = new ByteArray();
@@ -133,7 +138,6 @@ namespace StudySystemClient.Der {
public class Integer : Datum {
internal const uint8 TYPE = 0x02;
private const uint8 MAX_BYTES = 8;
public int64 value { get; private set; }
@@ -148,59 +152,6 @@ namespace StudySystemClient.Der {
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_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 {
@@ -266,7 +217,8 @@ namespace StudySystemClient.Der {
}
public class Choice : Datum {
internal const uint8 BASE_TYPE = 0x80;
internal const uint8 BASE_TYPE = 0xa0;
internal const uint8 ID_MASK = 0x1f;
public int id { get; private set; }
public Datum value { get; private set; }
@@ -282,8 +234,97 @@ namespace StudySystemClient.Der {
throws DecodeError {
this.type = type;
content = bytes;
id = type & 0x3f;
id = type & ID_MASK;
value = decode(bytes);
}
}
public class Null : Datum {
internal const uint8 TYPE = 0x05;
public Null() {
type = TYPE;
content = new uint8[] {};
}
internal Null.from_content(uint8[] bytes) throws DecodeError {
if (bytes.length != 0) {
throw new DecodeError.INVALID_CONTENT(
"Non-empty content for NULL");
}
type = TYPE;
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;
}
}

View File

@@ -0,0 +1,52 @@
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;
}
}
}
}

20
client/src/list_view.vala Normal file
View File

@@ -0,0 +1,20 @@
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);
}
}
}

View File

@@ -1,5 +1,3 @@
using Gtk;
namespace StudySystemClient {
public class App : Gtk.Application {
public App() {
@@ -8,11 +6,20 @@ namespace StudySystemClient {
protected override void activate() {
try {
var connection = new Connection(Config.CERT_DIR);
new MainWindow(this, connection);
var css_provider = new Gtk.CssProvider();
css_provider.load_from_resource(
"/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) {
stderr.printf("Failed to initialize connection: %s\n",
e.message);
stderr.printf("Failed to initialize: %s\n", e.message);
return;
}
}
}

View File

@@ -1,39 +1,49 @@
namespace StudySystemClient {
const string title = "Study System Client";
public class MainWindow : Gtk.ApplicationWindow {
private Connection connection;
private Gtk.Button send_button;
private Gtk.Label response_label;
public MainWindow(Gtk.Application app, Connection connection) {
public MainWindow(Gtk.Application app, Client client) {
Object(application: app);
default_width = 360;
default_height = 580;
this.connection = connection;
connection.received.connect((msg) => {
response_label.label = "Response: " + (string)msg;
var header_bar = new Gtk.HeaderBar();
var title = new Gtk.Label("Study System Client");
title.add_css_class("title");
header_bar.title_widget = title;
set_titlebar(header_bar);
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(() => connection.send("Foo".data));
box.append(send_button);
response_label = new Gtk.Label("");
response_label.wrap = true;
box.append(response_label);
set_child(box);
present();
}
}
}

View File

@@ -10,12 +10,24 @@ configure_file(
lib = library(
'study-system-client',
sources: files(
'activities_view.vala',
'activity.vala',
'activity_card.vala',
'client.vala',
'connection.vala',
'der.vala',
'iterable_box.vala',
'list_view.vala',
'main_window.vala',
),
'periodic.vala',
'refresher.vala',
'request.vala',
'response.vala',
'session_manager.vala',
) + resources,
dependencies: [gtk_dep],
vala_vapi: 'study-system-client.vapi'
vala_vapi: 'study-system-client.vapi',
vala_args: ['--pkg', 'gtk4']
)
lib_dep = declare_dependency(
link_with: lib,

35
client/src/periodic.vala Normal file
View File

@@ -0,0 +1,35 @@
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);
}
}
}
}

23
client/src/refresher.vala Normal file
View File

@@ -0,0 +1,23 @@
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;
});
}
}
}
}

54
client/src/request.vala Normal file
View File

@@ -0,0 +1,54 @@
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));
}
}
}

176
client/src/response.vala Normal file
View File

@@ -0,0 +1,176 @@
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");
}
}
}

View File

@@ -0,0 +1,173 @@
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;
}
}
}

29
client/styles.css Normal file
View File

@@ -0,0 +1,29 @@
.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;
}

View File

@@ -98,6 +98,32 @@ 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) {
Test.init(ref args);
@@ -175,11 +201,19 @@ void main(string[] args) {
Test.add_func("/encode/choice/1:foo", () => {
var choice = new Der.Choice(1, new Utf8String("foo"));
var expected = new uint8[] {
0x81, 0x05, 0x0c, 0x03, 0x66, 0x6f, 0x6f
0xa1, 0x05, 0x0c, 0x03, 0x66, 0x6f, 0x6f
};
test_encode(choice, expected);
});
Test.add_func("/encode/null", () => {
test_encode(new Der.Null(), { 0x05, 0x00 });
});
Test.add_func("/encode/enumerated/42", () => {
test_encode(new Enumerated(42), {0x0a, 0x01, 0x2a});
});
/*
* Decoding
*/
@@ -266,7 +300,7 @@ void main(string[] args) {
Test.add_func("/decode/choice/1:foo", () => {
var expected_id = 1;
var bytes = new uint8[] {
0x81, 0x05, 0x0c, 0x03, 0x66, 0x6f, 0x6f
0xa1, 0x05, 0x0c, 0x03, 0x66, 0x6f, 0x6f
};
Der.Choice choice;
try {
@@ -289,5 +323,26 @@ void main(string[] args) {
test_utf8string_value("foo", choice.value);
});
Test.add_func("/decode/null", () => {
var bytes = new uint8[] { 0x05, 0x00 };
Der.Null @null;
try {
@null = decode(bytes) as Der.Null;
} catch (DecodeError err) {
Test.message("Decoding failed: %s", err.message);
Test.fail();
return;
}
if (@null == null) {
Test.message("Bytes were not decoded as a NULL");
Test.fail();
return;
}
});
Test.add_func("/decode/enumerated/42", () => {
test_decode_enumerated({0x0a, 0x01, 0x2a}, 42);
});
Test.run();
}

2
server/.gitignore vendored
View File

@@ -1,2 +1,4 @@
_build/*
*.beam
src/StudySystemProtocol.erl
include/*

View File

@@ -0,0 +1,49 @@
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

View File

@@ -1,2 +1,6 @@
{erl_opts, [debug_info]}.
{deps, []}.
{plugins, [{provider_asn1, "0.4.1"}]}.
{provider_hooks, [{pre, [{compile, {asn, compile}}]},
{post, [{clean, {asn, clean}}]}]}.
{asn1_args, [{compile_opts, [der]}]}.

View File

@@ -11,10 +11,16 @@ start_link(Port, CertDir) ->
supervisor:start_link({local, ?MODULE}, ?MODULE, [Port, CertDir]).
init([Port, CertDir]) ->
SupFlags = #{stragegy => one_for_all,
intensity => 1,
period => 5},
ChildSpecs = [#{id => tcp_server,
SupFlags = #{strategy => one_for_one,
intensity => 5,
period => 10},
ChildSpecs = [#{id => session_sup,
start => {session_sup, start_link, []},
restart => permanent,
shutdown => 5000,
type => supervisor,
modules => [session_sup]},
#{id => tcp_server,
start => {tcp_server, start_link, [Port, CertDir]},
restart => permanent,
shutdown => 5000,

View File

@@ -0,0 +1,84 @@
% Copyright (c) Camden Dixie O'Brien
% SPDX-License-Identifier: AGPL-3.0-only
-module(session_server).
-behaviour(gen_server).
-export([start_link/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
start_link(Socket) ->
gen_server:start_link(?MODULE, Socket, []).
init(Socket) ->
ok = ssl:controlling_process(Socket, self()),
ok = ssl:setopts(Socket, [{active, true}]),
process_flag(trap_exit, true),
{ok, #{socket => Socket, transactions => #{}}}.
handle_call(_Request, _From, State) ->
{reply, ok, State}.
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info({ssl, Socket, Data}, State = #{transactions := Transactions}) ->
case 'StudySystemProtocol':decode('Request', Data) of
{ok, {'Request', TransactionId, RequestBody}} ->
Pid = spawn_link(fun() -> handle_request(RequestBody) end),
NewTransactions = maps:put(Pid, TransactionId, Transactions),
{noreply, State#{transactions := NewTransactions}};
{error, {asn1, _Reason}} ->
send(Socket, -1, {error, invalidRequest}),
{noreply, State}
end;
handle_info({'EXIT', Pid, Reason},
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) ->
{noreply, State}.
terminate(_Reason, #{socket := Socket}) ->
ssl:close(Socket).
code_change(_OldVsn, State, _Extra) ->
{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).

View File

@@ -0,0 +1,25 @@
% Copyright (c) Camden Dixie O'Brien
% SPDX-License-Identifier: AGPL-3.0-only
-module(session_sup).
-behaviour(supervisor).
-export([start_link/0, init/1, start_session/1]).
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).
init([]) ->
SupFlags = #{strategy => simple_one_for_one,
intensity => 5,
period => 10},
ChildSpec = #{id => session_server,
start => {session_server, start_link, []},
restart => temporary,
shutdown => 5000,
type => worker,
modules => [session_server]},
{ok, {SupFlags, [ChildSpec]}}.
start_session(Socket) ->
supervisor:start_child(?MODULE, [Socket]).

View File

@@ -9,7 +9,11 @@
start(_StartType, _StartArgs) ->
Port = application:get_env(study_system_server, port, 12888),
{ok, CertDir} = application:get_env(study_system_server, cert_dir),
proto_sup:start_link(Port, CertDir).
{ok, _Pid} = pg:start(study_system_server),
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) ->
ok.

View File

@@ -0,0 +1,35 @@
% 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}}.

View File

@@ -0,0 +1,86 @@
% 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}).

View File

@@ -0,0 +1,60 @@
% 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}.

View File

@@ -0,0 +1,25 @@
% 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]).

View File

@@ -8,7 +8,7 @@
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
terminate/2, code_change/3]).
-record(state, {socket, acceptor}).
-record(state, {socket}).
start_link(Port, CertDir) ->
gen_server:start_link({local, ?MODULE}, ?MODULE, [Port, CertDir], []).
@@ -20,8 +20,8 @@ init([Port, CertDir]) ->
{verify, verify_peer},
{fail_if_no_peer_cert, true}],
{ok, Socket} = ssl:listen(Port, TcpOpts ++ SslOpts),
Pid = spawn_link(fun() -> acceptor_loop(Socket) end),
{ok, #state{socket = Socket, acceptor = Pid}}.
self() ! accept,
{ok, #state{socket = Socket}}.
handle_call(_Request, _From, State) ->
{reply, ok, State}.
@@ -29,46 +29,29 @@ handle_call(_Request, _From, State) ->
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info({'EXIT', Pid, Reason}, State = #state{acceptor = Pid}) ->
{stop, {acceptor_died, Reason}, State};
handle_info(accept, State = #state{socket = Socket}) ->
case ssl:transport_accept(Socket) of
{ok, TlsSocket} ->
self() ! {handshake, TlsSocket},
self() ! accept;
{error, closed} ->
ok
end,
{noreply, State};
handle_info({handshake, TlsSocket}, State) ->
case ssl:handshake(TlsSocket, 5000) of
{ok, ClientSocket} ->
{ok, _Pid} = session_sup:start_session(ClientSocket);
{error, _Reason} ->
ok
end,
{noreply, State};
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, #state{socket = Socket}) ->
gen_tcp:close(Socket),
ssl:close(Socket),
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
acceptor_loop(Socket) ->
case ssl:transport_accept(Socket) of
{ok, TlsSocket} ->
case ssl:handshake(TlsSocket) of
{ok, ClientSocket} ->
Pid = spawn(
fun() -> handle_connection(ClientSocket) end),
ok = ssl:controlling_process(ClientSocket, Pid),
acceptor_loop(Socket);
{error, _Reason} ->
acceptor_loop(Socket)
end;
{error, closed} ->
ok
end.
handle_connection(Socket) ->
ssl:setopts(Socket, [{active, true}]),
handle_connection_loop(Socket).
handle_connection_loop(Socket) ->
receive
{ssl, Socket, Data} ->
handle_client_msg(Socket, Data),
handle_connection_loop(Socket);
{ssl_closed, Socket} ->
ok
end.
handle_client_msg(Socket, Msg) ->
ssl:send(Socket, Msg).