Compare commits

..

55 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
18541786e1 Add support for CHOICE to client DER encode/decode logic 2025-02-25 18:17:30 +00:00
046deccc26 Write script for rebuilding and running the client and server 2025-02-25 17:34:22 +00:00
b522ef8a98 Implement connection recovery procedures in client 2025-02-25 17:30:23 +00:00
53857bc613 Fix message dropping issue on server
The sockets were initially set to active, which meant that data
received immediately after the TLS handshake (before the socket was
transferred to the handle_connection process) were received by the
acceptor process and hence were not handled.
2025-02-25 16:00:04 +00:00
538405fec9 Move child spec into proto_sup 2025-02-25 16:00:04 +00:00
4eceba338c Remove debug io:format() from tcp_server 2025-02-25 16:00:04 +00:00
ef9e578e25 Implement SEQUENCE encoding and decoding in client 2025-02-25 16:00:04 +00:00
5df58e9d28 Implement DER decoding for BOOLEAN, INTEGER and UTF8String in Client 2025-02-25 16:00:04 +00:00
4b22bd726f Implement DER encoding for BOOLEAN, INTEGER and UTF8String in Client 2025-02-25 16:00:04 +00:00
31712d5efa Move Connection and MainWindow classes to library 2025-02-25 16:00:04 +00:00
27ecce1211 Check in project plan 2025-02-23 19:28:36 +00:00
30d03e3739 Move client sources into client/src dir 2025-02-23 10:25:42 +00:00
37 changed files with 2337 additions and 164 deletions

View File

@@ -1,99 +0,0 @@
using Gtk;
public class Connection {
public signal void response_received(uint8[] response);
private TlsClientConnection tls_client;
public Connection() throws Error {
var loopback = new InetAddress.loopback(SocketFamily.IPV6);
var host = new InetSocketAddress(loopback, 12888);
var db_type = TlsBackend.get_default().get_file_database_type();
const string ca_path = Config.CERT_DIR + "/ca.pem";
var db = Object.new(db_type, "anchors", ca_path) as TlsDatabase;
var cert =
new TlsCertificate.from_file(Config.CERT_DIR + "/client.pem");
var plain_client = new SocketClient();
var plain_connection = plain_client.connect(host);
tls_client = TlsClientConnection.new(plain_connection, host);
tls_client.set_database(db);
tls_client.set_certificate(cert);
tls_client.handshake();
}
public async void send(uint8[] message) throws Error {
yield tls_client.output_stream.write_async(message);
var response = new uint8[1024];
var len = yield tls_client.input_stream.read_async(response);
response_received(response[0:len]);
}
}
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) {
Object(application: app);
default_width = 600;
default_height = 400;
title = "Study System Client";
this.connection = connection;
connection.response_received.connect((response) => {
response_label.label = "Response: " + (string)response;
});
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(on_send_clicked);
box.append(send_button);
response_label = new Gtk.Label("");
response_label.wrap = true;
box.append(response_label);
set_child(box);
present();
}
private async void on_send_clicked() {
try {
yield connection.send("Foo".data);
} catch (Error e) {
response_label.label = "Error: " + e.message;
}
}
}
public class StudySystemClient : Gtk.Application {
public StudySystemClient() {
Object(application_id: "sh.wip.study-system-client");
}
protected override void activate() {
try {
var connection = new Connection();
new MainWindow(this, connection);
} catch (Error e) {
stderr.printf("Failed to initialize connection: %s\n",
e.message);
}
}
public static int main(string[] args) {
var app = new StudySystemClient();
return app.run(args);
}
}

View File

@@ -9,22 +9,14 @@ project(
add_project_arguments('-w', language: 'c') add_project_arguments('-w', language: 'c')
gtk_dep = dependency('gtk4') gtk_dep = dependency('gtk4')
gnome = import('gnome')
conf = configuration_data() resources = gnome.compile_resources(
conf.set_quoted( 'resources',
'CONFIG_CERT_DIR', 'resources.gresource.xml',
join_paths(meson.project_source_root(), '..', 'test')) source_dir: '.',
configure_file( c_name: 'resources'
output: 'config.h',
configuration: conf
) )
exe = executable( subdir('src')
'study-system-client', subdir('tests')
[
'main.vala',
'config.vapi'
],
dependencies: [gtk_dep],
c_args: ['-w']
)

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");
}
}
}
}

122
client/src/connection.vala Normal file
View File

@@ -0,0 +1,122 @@
namespace StudySystemClient {
public class Connection {
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,
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) => receive(msg),
(connected) => update_status(connected));
transaction_manager = new TransactionManager();
worker = new Worker(session_manager);
connected = true;
}
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 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;
public Worker(SessionManager session_manager) {
base(TASK_PERIOD_MS);
this.session_manager = session_manager;
start();
}
protected override void task() {
session_manager.task();
}
}
}

330
client/src/der.vala Normal file
View File

@@ -0,0 +1,330 @@
namespace StudySystemClient.Der {
public errordomain DecodeError {
INCOMPLETE,
INVALID_CONTENT,
UNKNOWN_TYPE,
}
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 {
if (bytes.length < BASE_HEADER_SIZE) {
throw new DecodeError.INCOMPLETE(
"Message is fewer than %u bytes", BASE_HEADER_SIZE);
}
uint header_size;
var length = decode_length(bytes, out header_size);
if (header_size + length > bytes.length) {
throw new DecodeError.INCOMPLETE(
"Length %u but only %u bytes available", length,
bytes.length - header_size);
}
var content = bytes[header_size:header_size + length];
size = header_size + length;
return decode_datum(bytes[0], content);
}
private static uint decode_length(uint8[] bytes, out uint header_size)
throws DecodeError {
if ((bytes[1] & 0x80) != 0) {
var length_size = bytes[1] & 0x7f;
if (BASE_HEADER_SIZE + length_size > bytes.length) {
throw new DecodeError.INCOMPLETE(
"Length with size %u but only %u bytes available",
length_size, bytes.length - BASE_HEADER_SIZE);
}
var length = 0;
for (int i = 0; i < length_size; ++i)
length = length << 8 | bytes[BASE_HEADER_SIZE + i];
header_size = BASE_HEADER_SIZE + length_size;
return length;
} else {
header_size = BASE_HEADER_SIZE;
return bytes[1];
}
}
private static Datum decode_datum(uint8 type, uint8[] content)
throws DecodeError {
if ((type & ~Choice.ID_MASK) == Choice.BASE_TYPE)
return new Choice.from_content(type, content);
switch (type) {
case Boolean.TYPE:
return new Boolean.from_content(content);
case Integer.TYPE:
return new Integer.from_content(content);
case Utf8String.TYPE:
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);
}
}
public abstract class Datum {
protected uint8 type;
protected uint8[] content;
public uint8[] encode() {
var buffer = new ByteArray();
buffer.append({type});
if (content.length >= 0x80) {
var length_bytes = encode_length(content.length);
buffer.append({0x80 | (uint8)length_bytes.length});
buffer.append(length_bytes);
} else {
buffer.append({(uint8)content.length});
}
buffer.append(content);
return buffer.data;
}
private static uint8[] encode_length(uint length) {
var buffer = new ByteArray();
int shift = 0;
while (length >> (shift + 8) != 0)
shift += 8;
for (; shift >= 0; shift -= 8)
buffer.append({(uint8)(length >> shift)});
return buffer.data;
}
}
public class Boolean : Datum {
internal const uint8 TYPE = 0x01;
public bool value { get; private set; }
public Boolean(bool val) {
type = TYPE;
content = new uint8[] { val ? 0xff : 0x00 };
value = val;
}
internal Boolean.from_content(uint8[] bytes) throws DecodeError {
type = TYPE;
content = bytes;
value = decode_bool(content);
}
internal static bool decode_bool(uint8[] bytes)
throws DecodeError {
if (bytes.length != 1) {
throw new DecodeError.INVALID_CONTENT(
"Invalid length for boolean: %u", bytes.length);
}
switch (bytes[0]) {
case 0xff:
return true;
case 0x00:
return false;
default:
throw new DecodeError.INVALID_CONTENT(
"Invalid value of boolean: %02x", bytes[0]);
}
}
}
public class Integer : Datum {
internal const uint8 TYPE = 0x02;
public int64 value { get; private set; }
public Integer(int64 val) {
type = TYPE;
content = encode_int64(val);
value = val;
}
internal Integer.from_content(uint8[] bytes) throws DecodeError {
type = TYPE;
content = bytes;
value = decode_int64(content);
}
}
public class Utf8String : Datum {
internal const uint8 TYPE = 0x0c;
public string value { get; private set; }
public Utf8String(string val) {
type = TYPE;
content = val.data;
value = val;
}
public Utf8String.from_content(uint8[] bytes) {
type = TYPE;
content = bytes;
value = decode_string(bytes);
}
private static string decode_string(uint8[] bytes) {
var buffer = new uint8[bytes.length + 1];
Memory.copy(buffer, bytes, bytes.length);
buffer[bytes.length] = 0;
return (string)buffer;
}
}
public class Sequence : Datum {
internal const uint8 TYPE = 0x30;
public Datum[] value { get; private set; }
public Sequence(Datum[] val) {
type = TYPE;
content = encode_array(val);
value = val;
}
internal Sequence.from_content(uint8[] bytes) throws DecodeError {
type = TYPE;
content = bytes;
value = decode_array(bytes);
}
private static uint8[] encode_array(Datum[] val) {
var buffer = new ByteArray();
foreach (var datum in val)
buffer.append(datum.encode());
return buffer.data;
}
private static Datum[] decode_array(uint8[] bytes)
throws DecodeError {
var elems = new GenericArray<Datum>();
uint offset = 0;
uint size;
while (offset < bytes.length) {
elems.add(decode(bytes[offset:], out size));
offset += size;
}
return elems.data;
}
}
public class Choice : Datum {
internal const uint8 BASE_TYPE = 0xa0;
internal const uint8 ID_MASK = 0x1f;
public int id { get; private set; }
public Datum value { get; private set; }
public Choice(int id, Datum val) {
type = BASE_TYPE | id;
content = val.encode();
this.id = id;
value = val;
}
internal Choice.from_content(uint8 type, uint8[] bytes)
throws DecodeError {
this.type = type;
content = bytes;
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);
}
}
}

31
client/src/main.vala Normal file
View File

@@ -0,0 +1,31 @@
namespace StudySystemClient {
public class App : Gtk.Application {
public App() {
Object(application_id: "sh.wip.study-system-client");
}
protected override void activate() {
try {
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: %s\n", e.message);
return;
}
}
}
}
int main(string[] args) {
var app = new StudySystemClient.App();
return app.run(args);
}

View File

@@ -0,0 +1,49 @@
namespace StudySystemClient {
public class MainWindow : Gtk.ApplicationWindow {
public MainWindow(Gtk.Application app, Client client) {
Object(application: app);
default_width = 360;
default_height = 580;
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;
});
}
}
}

45
client/src/meson.build Normal file
View File

@@ -0,0 +1,45 @@
conf = configuration_data()
conf.set_quoted(
'CONFIG_CERT_DIR',
join_paths(meson.project_source_root(), '..', 'test'))
configure_file(
output: 'config.h',
configuration: conf
)
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_args: ['--pkg', 'gtk4']
)
lib_dep = declare_dependency(
link_with: lib,
include_directories: include_directories('.')
)
exe = executable(
'study-system-client',
sources: files(
'config.vapi',
'main.vala',
),
dependencies: [lib_dep, gtk_dep],
c_args: ['-w']
)

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;
}

348
client/tests/der_tests.vala Normal file
View File

@@ -0,0 +1,348 @@
using StudySystemClient;
using StudySystemClient.Der;
static bool bytes_equal(uint8[] expected, uint8[] actual) {
if (expected.length != actual.length)
return false;
return Memory.cmp(expected, actual, expected.length) == 0;
}
static string bytes_to_string(uint8[] bytes) {
var s = "";
foreach (var byte in bytes)
s += "%02x".printf(byte);
return s;
}
static void test_encode(Datum datum, uint8[] expected) {
var bytes = datum.encode();
if (!bytes_equal(expected, bytes)) {
Test.message("Encoding is incorrect: expected %s got %s",
bytes_to_string(expected), bytes_to_string(bytes));
Test.fail();
return;
}
}
static void test_decode_boolean(uint8[] bytes, bool expected) {
Boolean boolean;
try {
boolean = decode(bytes) as Boolean;
} catch (DecodeError err) {
Test.message("Decoding failed: %s", err.message);
Test.fail();
return;
}
if (boolean == null) {
Test.message("Bytes were not decoded as a BOOLEAN");
Test.fail();
return;
}
if (boolean.value != expected) {
Test.message(@"Expected $expected got $(boolean.value)");
Test.fail();
return;
}
}
static void test_decode_integer(uint8[] bytes, int64 expected) {
Datum datum;
try {
datum = decode(bytes);
} catch (DecodeError err) {
Test.message("Decoding failed: %s", err.message);
Test.fail();
return;
}
test_integer_value(expected, datum);
}
static void test_decode_utf8string(uint8[] bytes, string expected) {
Datum datum;
try {
datum = decode(bytes);
} catch (DecodeError err) {
Test.message("Decoding failed: %s", err.message);
Test.fail();
return;
}
test_utf8string_value(expected, datum);
}
static void test_integer_value(int64 expected, Datum datum) {
var integer = datum as Integer;
if (integer == null) {
Test.message("Bytes were not decoded as a INTEGER");
Test.fail();
return;
}
if (integer.value != expected) {
Test.message(@"Expected $expected got $(integer.value)");
Test.fail();
return;
}
}
static void test_utf8string_value(string expected, Datum datum) {
var utf8string = datum as Utf8String;
if (utf8string == null) {
Test.message("Bytes were not decoded as a UTF8String");
Test.fail();
return;
}
if (utf8string.value != expected) {
Test.message(@"Expected $expected got $(utf8string.value)");
Test.fail();
return;
}
}
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);
/*
* Encoding
*/
Test.add_func("/encode/boolean/true", () => {
test_encode(new Boolean(true), {0x01, 0x01, 0xff});
});
Test.add_func("/encode/boolean/false", () => {
test_encode(new Boolean(false), {0x01, 0x01, 0x00});
});
Test.add_func("/encode/integer/small/0", () => {
test_encode(new Integer(0), {0x02, 0x01, 0x00});
});
Test.add_func("/encode/integer/small/5", () => {
test_encode(new Integer(5), {0x02, 0x01, 0x05});
});
Test.add_func("/encode/integer/small/42", () => {
test_encode(new Integer(42), {0x02, 0x01, 0x2a});
});
Test.add_func("/encode/integer/large/1337", () => {
test_encode(new Integer(1337), {0x02, 0x02, 0x05, 0x39});
});
Test.add_func("/encode/integer/sign/128", () => {
test_encode(new Integer(128), {0x02, 0x02, 0x00, 0x80});
});
Test.add_func("/encode/integer/sign/0xbeef", () => {
test_encode(new Integer(0xbeef), {0x02, 0x03, 0x00, 0xbe, 0xef});
});
Test.add_func("/encode/integer/sign/-128", () => {
test_encode(new Integer(-128), {0x02, 0x01, 0x80});
});
Test.add_func("/encode/integer/sign/-1337", () => {
test_encode(new Integer(-1337), {0x02, 0x02, 0xfa, 0xc7});
});
Test.add_func("/encode/utf8string/short/foo", () => {
test_encode(new Utf8String("foo"),
{0x0c, 0x03, 0x66, 0x6f, 0x6f});
});
Test.add_func("/encode/utf8string/short/bar", () => {
test_encode(new Utf8String("bar"),
{0x0c, 0x03, 0x62, 0x61, 0x72});
});
Test.add_func("/encode/utf8string/long/x300", () => {
var expected = new uint8[304];
expected[0] = 0x0c;
expected[1] = 0x82;
expected[2] = 0x01;
expected[3] = 0x2c;
Memory.set(expected[4:], 0x78, 300);
test_encode(new Utf8String(string.nfill(300, 'x')), expected);
});
Test.add_func("/encode/utf8string/long/x128", () => {
var expected = new uint8[131];
expected[0] = 0x0c;
expected[1] = 0x81;
expected[2] = 0x80;
Memory.set(expected[3:], 0x78, 128);
test_encode(new Utf8String(string.nfill(128, 'x')), expected);
});
Test.add_func("/encode/sequence/foo,42", () => {
var sequence = new Der.Sequence(
{new Utf8String("foo"), new Integer(42)});
var expected = new uint8[] {
0x30, 0x08, 0x0c, 0x03, 0x66, 0x6f, 0x6f, 0x02, 0x01, 0x2a
};
test_encode(sequence, expected);
});
Test.add_func("/encode/choice/1:foo", () => {
var choice = new Der.Choice(1, new Utf8String("foo"));
var expected = new uint8[] {
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
*/
Test.add_func("/decode/boolean/true", () => {
test_decode_boolean({0x01, 0x01, 0xff}, true);
});
Test.add_func("/decode/boolean/false", () => {
test_decode_boolean({0x01, 0x01, 0x00}, false);
});
Test.add_func("/decode/integer/small/42", () => {
test_decode_integer({0x02, 0x01, 0x2a}, 42);
});
Test.add_func("/decode/integer/large/1337", () => {
test_decode_integer({0x02, 0x02, 0x05, 0x39}, 1337);
});
Test.add_func("/decode/integer/sign/128", () => {
test_decode_integer({0x02, 0x02, 0x00, 0x80}, 128);
});
Test.add_func("/decode/integer/sign/0xbeef", () => {
test_decode_integer({0x02, 0x03, 0x00, 0xbe, 0xef}, 0xbeef);
});
Test.add_func("/decode/integer/sign/-128", () => {
test_decode_integer({0x02, 0x01, 0x80}, -128);
});
Test.add_func("/decode/integer/sign/-1337", () => {
test_decode_integer({0x02, 0x02, 0xfa, 0xc7}, -1337);
});
Test.add_func("/decode/utf8string/short/foo", () => {
test_decode_utf8string({0x0c, 0x03, 0x66, 0x6f, 0x6f}, "foo");
});
Test.add_func("/decode/utf8string/short/bar", () => {
test_decode_utf8string({0x0c, 0x03, 0x62, 0x61, 0x72}, "bar");
});
Test.add_func("/decode/utf8string/long/x300", () => {
var bytes = new uint8[304];
bytes[0] = 0x0c;
bytes[1] = 0x82;
bytes[2] = 0x01;
bytes[3] = 0x2c;
Memory.set(bytes[4:], 0x78, 300);
test_decode_utf8string(bytes, string.nfill(300, 'x'));
});
Test.add_func("/decode/utf8string/long/x128", () => {
var bytes = new uint8[131];
bytes[0] = 0x0c;
bytes[1] = 0x81;
bytes[2] = 0x80;
Memory.set(bytes[3:], 0x78, 128);
test_decode_utf8string(bytes, string.nfill(128, 'x'));
});
Test.add_func("/decode/sequence/foo,42", () => {
var expected_len = 2;
var bytes = new uint8[] {
0x30, 0x08, 0x0c, 0x03, 0x66, 0x6f, 0x6f, 0x02, 0x01, 0x2a
};
Der.Sequence sequence;
try {
sequence = decode(bytes) as Der.Sequence;
} catch (DecodeError err) {
Test.message("Decoding failed: %s", err.message);
Test.fail();
return;
}
if (sequence == null) {
Test.message("Bytes were not decoded as a SEQUENCE");
Test.fail();
return;
}
Datum[] elems = sequence.value;
if (elems.length != expected_len) {
Test.message(
@"Expected $expected_len elements, got $(elems.length)");
Test.fail();
return;
}
test_utf8string_value("foo", elems[0]);
test_integer_value(42, elems[1]);
});
Test.add_func("/decode/choice/1:foo", () => {
var expected_id = 1;
var bytes = new uint8[] {
0xa1, 0x05, 0x0c, 0x03, 0x66, 0x6f, 0x6f
};
Der.Choice choice;
try {
choice = decode(bytes) as Der.Choice;
} catch (DecodeError err) {
Test.message("Decoding failed: %s", err.message);
Test.fail();
return;
}
if (choice == null) {
Test.message("Bytes were not decoded as a CHOICE");
Test.fail();
return;
}
if (choice.id != expected_id) {
Test.message(@"Expected ID $expected_id, got $(choice.id)");
Test.fail();
return;
}
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();
}

8
client/tests/meson.build Normal file
View File

@@ -0,0 +1,8 @@
test(
'DER tests',
executable(
'der_tests',
'der_tests.vala',
dependencies: [lib_dep, gtk_dep]
)
)

46
plan.txt Normal file
View File

@@ -0,0 +1,46 @@
STUDY SYSTEM
GOALS
1. Support consistent and balanced study across multiple subjects
2. Help prioritize what to study when time/energy is available
3. Low friction of usage
4. Apply cybernetic principles to self-directed education
NON-GOALS
1. Setting targets for time spent studying
2. Measuring effectiveness of studies
3. Simplicity of set-up for non-technical users
4. Reading mail
CORE FEATURES
Priority System:
- Prioritized list of potential study activities
- Separate tracking of reading and exercises
- Neglected subjects increase in priority
- Balance maintenance between reading and exercises
Study health monitoring:
- Subject-level algedonic signals indicating consistency and
balance between reading and exercises
- System-wide algedonic signal indicating balance across subjects
IMPLEMENTATION DECISIONS
- Client/server to enable consistency across multiple devices
- Simple over-the-wire protocol so that future client
implementations for different platforms are feasible
(e.g. Android app)
- TCP with TLS for persistent connection
- Client authentication via certificates (mTLS) for simplicity of
implementation and security
- Server implementation in Erlang/OTP as it's very cool and I want
more practise with it
- ASN.1/DER for message serialization as it's flexible and reasonably
compact and has good Erlang integration
- Native Linux client because I hate the web
- Client implementation in GTK+Vala because it's boring

13
scripts/build-run.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
set -e
cd client
if [ ! -d build ]; then
meson setup build client
fi
meson compile -C build
build/src/study-system-client &
cd ..
cd server
rebar3 shell --apps study_system_server

2
server/.gitignore vendored
View File

@@ -1,2 +1,4 @@
_build/* _build/*
*.beam *.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]}. {erl_opts, [debug_info]}.
{deps, []}. {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,8 +11,19 @@ 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 = #{stragegy => one_for_all, SupFlags = #{strategy => one_for_one,
intensity => 1, intensity => 5,
period => 5}, period => 10},
ChildSpecs = [tcp_server:child_spec(Port, CertDir)], 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,
type => worker,
modules => [tcp_server]}],
{ok, {SupFlags, ChildSpecs}}. {ok, {SupFlags, ChildSpecs}}.

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) -> 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),
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) -> stop(_State) ->
ok. 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

@@ -4,33 +4,24 @@
-module(tcp_server). -module(tcp_server).
-behaviour(gen_server). -behaviour(gen_server).
-export([start_link/2, child_spec/2]). -export([start_link/2]).
-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, acceptor}). -record(state, {socket}).
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], []).
child_spec(Port, CertDir) ->
#{id => ?MODULE,
start => {?MODULE, start_link, [Port, CertDir]},
restart => permanent,
shutdown => 5000,
type => worker,
modules => [?MODULE]}.
init([Port, CertDir]) -> init([Port, CertDir]) ->
io:format("cert dir: ~p~n", [CertDir]), TcpOpts = [binary, inet6, {active, false}, {reuseaddr, true}],
SslOpts = [{certfile, filename:join([CertDir, "server.pem"])}, SslOpts = [{certfile, filename:join([CertDir, "server.pem"])},
{cacertfile, filename:join([CertDir, "ca.pem"])}, {cacertfile, filename:join([CertDir, "ca.pem"])},
{verify, verify_peer}, {verify, verify_peer},
{fail_if_no_peer_cert, true}], {fail_if_no_peer_cert, true}],
{ok, Socket} = {ok, Socket} = ssl:listen(Port, TcpOpts ++ SslOpts),
ssl:listen(Port, [binary, inet6, {active, true} | SslOpts]), self() ! accept,
Pid = spawn_link(fun() -> acceptor_loop(Socket) end), {ok, #state{socket = Socket}}.
{ok, #state{socket = Socket, acceptor = Pid}}.
handle_call(_Request, _From, State) -> handle_call(_Request, _From, State) ->
{reply, ok, State}. {reply, ok, State}.
@@ -38,40 +29,29 @@ handle_call(_Request, _From, State) ->
handle_cast(_Msg, State) -> handle_cast(_Msg, State) ->
{noreply, State}. {noreply, State}.
handle_info({'EXIT', Pid, Reason}, State = #state{acceptor = Pid}) -> handle_info(accept, State = #state{socket = Socket}) ->
{stop, {acceptor_died, Reason}, State}; 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) -> handle_info(_Info, State) ->
{noreply, State}. {noreply, State}.
terminate(_Reason, #state{socket = Socket}) -> terminate(_Reason, #state{socket = Socket}) ->
gen_tcp:close(Socket), ssl:close(Socket),
ok. ok.
code_change(_OldVsn, State, _Extra) -> code_change(_OldVsn, State, _Extra) ->
{ok, State}. {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),
ssl:setopts(ClientSocket, [{active, true}]),
acceptor_loop(Socket);
{error, _Reason} ->
acceptor_loop(Socket)
end;
{error, closed} ->
ok
end.
handle_connection(Socket) ->
receive
{ssl, Socket, Data} ->
ssl:send(Socket, Data),
handle_connection(Socket);
{ssl_closed, Socket} ->
ok
end.