Compare commits

..

12 Commits

14 changed files with 348 additions and 139 deletions

View File

@ -1,59 +1,31 @@
namespace StudySystemClient { namespace StudySystemClient {
public class ActivitiesView : Gtk.Box { public class ActivitiesView : CardArea<ActivityCard>, IRefreshable {
private const uint REFRESH_PERIOD_MS = 30000;
private Client client; private Client client;
private Gtk.FlowBox card_container; private Refresher refresher;
private RefreshingIndicator refreshing_indicator; private bool pending_sort;
public ActivitiesView(Client client) { public ActivitiesView(Client client) {
Object(orientation: Gtk.Orientation.VERTICAL,
hexpand: true,
vexpand: true,
margin_top: 0,
margin_bottom: 0,
margin_start: 0,
margin_end: 0);
this.client = client; this.client = client;
refresher = new Refresher(this, REFRESH_PERIOD_MS);
card_container = new Gtk.FlowBox(); pending_sort = false;
card_container.homogeneous = true; this.map.connect(() => {
card_container.min_children_per_line = 1; refresher.start();
card_container.max_children_per_line = 1; refresh.begin();
card_container.selection_mode = Gtk.SelectionMode.NONE; });
card_container.valign = Gtk.Align.START;
var scrolled_window = new Gtk.ScrolledWindow();
scrolled_window.hscrollbar_policy = Gtk.PolicyType.NEVER;
scrolled_window.hexpand = true;
scrolled_window.vexpand = true;
scrolled_window.add_css_class("card-container");
scrolled_window.set_child(card_container);
var overlay = new Gtk.Overlay();
overlay.hexpand = overlay.vexpand = true;
overlay.set_child(scrolled_window);
this.append(overlay);
refreshing_indicator = new RefreshingIndicator(overlay);
this.map.connect(refresh);
} }
private async void refresh() { private async void refresh() {
refreshing_indicator.show(); if (!client.connected)
return;
try { try {
var activities = yield client.list_activities(); var activities = yield client.list_activities();
card_container.remove_all(); update_cards(activities);
foreach (var activity in activities) {
var card = new ActivityCard(activity);
card.session_logged.connect(log_session);
card_container.append(card);
}
} catch (ClientError e) { } catch (ClientError e) {
stderr.printf("Error refreshing activities: %s\n", stderr.printf("Error refreshing activities: %s\n",
e.message); e.message);
} }
refreshing_indicator.hide();
} }
private async void log_session(string subject, ActivityType type, private async void log_session(string subject, ActivityType type,
@ -65,43 +37,78 @@ namespace StudySystemClient {
stderr.printf("Error logging session: %s\n", e.message); 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 class RefreshingIndicator { private void update_cards(Array<Activity> activities) {
private Gtk.Overlay overlay; update_existing_cards(activities);
private Gtk.Frame frame; for (uint i = 0; i < activities.length; ++i)
create_card(activities.index(i));
public RefreshingIndicator(Gtk.Overlay overlay) { if (log_in_progress())
this.overlay = overlay; pending_sort = true;
else
var label = new Gtk.Label("Refreshing"); container.sort(compare_cards);
label.halign = Gtk.Align.START;
var spinner = new Gtk.Spinner();
spinner.start();
var content = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6);
content.margin_top = content.margin_bottom
= content.margin_start = content.margin_end = 12;
content.append(label);
content.append(spinner);
frame = new Gtk.Frame(null);
frame.halign = Gtk.Align.CENTER;
frame.valign = Gtk.Align.START;
frame.add_css_class("osd");
frame.add_css_class("popdown");
frame.set_child(content);
overlay.add_overlay(frame);
} }
public void show() { private void update_existing_cards(Array<Activity> activities) {
frame.add_css_class("visible"); var to_remove = new List<ActivityCard>();
foreach (var card in container) {
if (!update_existing_card(card, activities))
to_remove.append(card);
}
foreach (var card in to_remove)
container.remove(card);
} }
public void hide() { private bool update_existing_card(ActivityCard card,
frame.remove_css_class("visible"); 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 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;
}
private void create_card(Activity activity) {
var card = new ActivityCard(activity);
card.session_logged.connect(log_session);
card.log_closed.connect(handle_pending_sort);
container.append(card);
}
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;
} }
} }
} }

View File

@ -1,10 +1,19 @@
namespace StudySystemClient { namespace StudySystemClient {
public class ActivityCard : Gtk.Frame { public class ActivityCard : Card {
public signal void session_logged(string subject, ActivityType type, public signal void session_logged(string subject, ActivityType type,
int minutes); 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) { public ActivityCard(Activity activity) {
add_css_class("card"); base();
this.activity = activity;
logging = false;
var subject = new Gtk.Label(activity.subject); var subject = new Gtk.Label(activity.subject);
subject.halign = Gtk.Align.START; subject.halign = Gtk.Align.START;
@ -14,13 +23,14 @@ namespace StudySystemClient {
type.add_css_class("activity-type"); type.add_css_class("activity-type");
var separator = new Gtk.Label("·"); var separator = new Gtk.Label("·");
separator.add_css_class("activity-priority"); separator.add_css_class("activity-priority");
var priority = new Gtk.Label("%0.2f".printf(activity.priority));
priority.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); var details = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6);
details.append(type); details.append(type);
details.append(separator); details.append(separator);
details.append(priority); details.append(priority_label);
var text = new Gtk.Box(Gtk.Orientation.VERTICAL, 6); var text = new Gtk.Box(Gtk.Orientation.VERTICAL, 6);
text.hexpand = true; text.hexpand = true;
@ -42,10 +52,27 @@ namespace StudySystemClient {
var log_session_popover = new LogSessionPopover(); var log_session_popover = new LogSessionPopover();
log_session_popover.set_parent(button); log_session_popover.set_parent(button);
button.clicked.connect(() => log_session_popover.popup()); log_session_popover.closed.connect(() => {
logging = false;
log_closed();
});
log_session_popover.session_logged.connect((minutes) => { log_session_popover.session_logged.connect((minutes) => {
session_logged(activity.subject, activity.type, 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);
} }
} }

28
client/src/card.vala Normal file
View File

@ -0,0 +1,28 @@
namespace StudySystemClient {
public class Card : Gtk.Frame {
public Card() {
hexpand = true;
add_css_class("card");
}
}
public class CardArea<T> : Gtk.Box {
protected IterableBox<T> container;
public CardArea() {
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.add_css_class("card-container");
scrolled_window.set_child(container);
append(scrolled_window);
}
}
}

View File

@ -5,10 +5,16 @@ namespace StudySystemClient {
} }
public class Client { public class Client {
public signal void connection_status(bool connected);
public bool connected { get { return connection.connected; } }
private Connection connection; private Connection connection;
public Client(string cert_dir) throws Error { public Client(string cert_dir) throws Error {
connection = new Connection(cert_dir); connection = new Connection(cert_dir, (connected) => {
connection_status(connected);
});
} }
public async void ping() throws ClientError { public async void ping() throws ClientError {

View File

@ -1,17 +1,27 @@
namespace StudySystemClient { namespace StudySystemClient {
public class Connection { public class Connection {
public delegate void StatusCallback(bool connected);
public bool connected { get; private set; }
private StatusCallback status_callback;
private SessionManager session_manager; private SessionManager session_manager;
private TransactionManager transaction_manager; private TransactionManager transaction_manager;
private Worker worker; 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 loopback = new InetAddress.loopback(SocketFamily.IPV6);
var session_factory var session_factory
= new SessionFactory(loopback, 12888, cert_dir); = new SessionFactory(loopback, 12888, cert_dir);
this.status_callback = (owned) status_callback;
session_manager = new SessionManager( session_manager = new SessionManager(
session_factory, (msg) => receive(msg)); session_factory, (msg) => receive(msg),
(connected) => update_status(connected));
transaction_manager = new TransactionManager(); transaction_manager = new TransactionManager();
worker = new Worker(session_manager); worker = new Worker(session_manager);
connected = true;
} }
public async Response.Body? send(Request.Body body) { public async Response.Body? send(Request.Body body) {
@ -35,6 +45,16 @@ namespace StudySystemClient {
return false; return false;
}, GLib.Priority.DEFAULT_IDLE); }, GLib.Priority.DEFAULT_IDLE);
} }
private void update_status(bool connected) {
Idle.add(() => {
if (connected != this.connected) {
this.connected = connected;
status_callback(connected);
}
return false;
}, GLib.Priority.DEFAULT_IDLE);
}
} }
private class Continuation { private class Continuation {
@ -84,29 +104,19 @@ namespace StudySystemClient {
} }
} }
private class Worker { private class Worker : Periodic {
private uint TASK_PERIOD_MS = 10; private const uint TASK_PERIOD_MS = 10;
private SessionManager session_manager; private SessionManager session_manager;
private bool exit;
private Thread<void> thread;
public Worker(SessionManager session_manager) { public Worker(SessionManager session_manager) {
base(TASK_PERIOD_MS);
this.session_manager = session_manager; this.session_manager = session_manager;
exit = false; start();
thread = new Thread<void>("connection_worker", body);
} }
~Worker() { protected override void task() {
exit = true;
thread.join();
}
private void body() {
while (!exit) {
session_manager.task(); session_manager.task();
Thread.usleep(1000 * TASK_PERIOD_MS);
}
} }
} }
} }

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

View File

@ -12,8 +12,38 @@ namespace StudySystemClient {
header_bar.title_widget = title; header_bar.title_widget = title;
set_titlebar(header_bar); set_titlebar(header_bar);
var connection_indicator = new ConnectionIndicator(client);
var activities_view = new ActivitiesView(client); var activities_view = new ActivitiesView(client);
set_child(activities_view);
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;
});
} }
} }
} }

View File

@ -13,10 +13,14 @@ lib = library(
'activities_view.vala', 'activities_view.vala',
'activity.vala', 'activity.vala',
'activity_card.vala', 'activity_card.vala',
'card.vala',
'client.vala', 'client.vala',
'connection.vala', 'connection.vala',
'der.vala', 'der.vala',
'iterable_box.vala',
'main_window.vala', 'main_window.vala',
'periodic.vala',
'refresher.vala',
'request.vala', 'request.vala',
'response.vala', 'response.vala',
'session_manager.vala', 'session_manager.vala',

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

View File

@ -3,19 +3,23 @@ namespace StudySystemClient {
public delegate void ReceiveCallback(owned uint8[] msg); public delegate void ReceiveCallback(owned uint8[] msg);
private const uint INIT_RECONNECT_WAIT_MS = 500; private const uint INIT_RECONNECT_WAIT_MS = 500;
private const uint MAX_RECONNECT_WAIT_MS = 60000; private const uint MAX_RECONNECT_WAIT_MS = 30000;
private const double RECONNECT_BACKOFF = 1.6; private const double RECONNECT_BACKOFF = 1.6;
private SessionFactory session_factory; private SessionFactory session_factory;
private ReceiveCallback receive_callback; private ReceiveCallback receive_callback;
private Connection.StatusCallback status_callback;
private Session? session; private Session? session;
private AsyncQueue<OutgoingMessage> queue; private AsyncQueue<OutgoingMessage> queue;
private uint reconnect_wait_ms; private uint reconnect_wait_ms;
public SessionManager(SessionFactory session_factory, public SessionManager(
owned ReceiveCallback receive_callback) { SessionFactory session_factory,
owned ReceiveCallback receive_callback,
owned Connection.StatusCallback status_callback) {
this.session_factory = session_factory; this.session_factory = session_factory;
this.receive_callback = (owned) receive_callback; this.receive_callback = (owned) receive_callback;
this.status_callback = (owned) status_callback;
this.session = null; this.session = null;
queue = new AsyncQueue<OutgoingMessage>(); queue = new AsyncQueue<OutgoingMessage>();
reconnect_wait_ms = INIT_RECONNECT_WAIT_MS; reconnect_wait_ms = INIT_RECONNECT_WAIT_MS;
@ -40,6 +44,7 @@ namespace StudySystemClient {
if (msg.should_retry()) if (msg.should_retry())
queue.push(msg); queue.push(msg);
session = null; session = null;
status_callback(false);
} }
private void try_start_session() { private void try_start_session() {
@ -47,7 +52,9 @@ namespace StudySystemClient {
session = session_factory.start_session(); session = session_factory.start_session();
session.received.connect((msg) => receive_callback(msg)); session.received.connect((msg) => receive_callback(msg));
reconnect_wait_ms = INIT_RECONNECT_WAIT_MS; reconnect_wait_ms = INIT_RECONNECT_WAIT_MS;
status_callback(true);
} catch (Error _) { } catch (Error _) {
status_callback(false);
Thread.usleep(1000 * reconnect_wait_ms); Thread.usleep(1000 * reconnect_wait_ms);
update_reconnect_wait(); update_reconnect_wait();
} }
@ -65,7 +72,7 @@ namespace StudySystemClient {
public class SessionFactory { public class SessionFactory {
private const string CA_FILENAME = "/ca.pem"; private const string CA_FILENAME = "/ca.pem";
private const string CERT_FILENAME = "/client.pem"; private const string CERT_FILENAME = "/client.pem";
private const uint TIMEOUT_S = 1; private const uint TIMEOUT_S = 2;
private InetSocketAddress host; private InetSocketAddress host;
private TlsCertificate cert; private TlsCertificate cert;

View File

@ -1,11 +1,11 @@
.card-container { .card-container {
background-color: mix(@theme_base_color, @theme_bg_color, 0.7); background-color: color-mix(in oklab, @theme_base_color,
@theme_bg_color 60%);
padding: 6px; padding: 6px;
} }
.card { .card {
border: 1px solid alpha(@theme_fg_color, 0.2); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
box-shadow: 0 1px 2px alpha(black, 0.15);
background-color: @theme_bg_color; background-color: @theme_bg_color;
padding: 12px 16px; padding: 12px 16px;
} }
@ -15,7 +15,7 @@
} }
.activity-priority { .activity-priority {
color: alpha(@theme_fg_color, 0.6); color: color-mix(in oklab, @theme_fg_color 60%, @theme_bg_color);
} }
/* /*
@ -27,20 +27,3 @@
margin-top: -2px; margin-top: -2px;
margin-left: 3px; margin-left: 3px;
} }
/*
* Couldn't find a built-in way to get the common OSD overlay style of
* rounding the bottom corners but not the top ones, so doing this
* myself with this CSS rule.
*/
overlay > frame.osd {
border-radius: 0 0 8px 8px;
}
.popdown {
transition: transform 200ms ease-in-out;
transform: translateY(-100px);
}
.popdown.visible {
transform: translateY(0);
}

View File

@ -12,6 +12,7 @@ start_link(Socket) ->
gen_server:start_link(?MODULE, Socket, []). gen_server:start_link(?MODULE, Socket, []).
init(Socket) -> init(Socket) ->
ok = ssl:controlling_process(Socket, self()),
ok = ssl:setopts(Socket, [{active, true}]), ok = ssl:setopts(Socket, [{active, true}]),
process_flag(trap_exit, true), process_flag(trap_exit, true),
{ok, #{socket => Socket, transactions => #{}}}. {ok, #{socket => Socket, transactions => #{}}}.
@ -38,9 +39,7 @@ handle_info({'EXIT', Pid, Reason},
TransactionId = maps:get(Pid, Transactions), TransactionId = maps:get(Pid, Transactions),
Response = case Reason of Response = case Reason of
{response, Value} -> Value; {response, Value} -> Value;
_ -> _ -> {error, serverError}
io:format("Error handling request: ~p~n", [Reason]),
{error, serverError}
end, end,
send(Socket, TransactionId, Response), send(Socket, TransactionId, Response),
{noreply, State#{transactions := maps:remove(Pid, Transactions)}}; {noreply, State#{transactions := maps:remove(Pid, Transactions)}};
@ -59,25 +58,24 @@ code_change(_OldVsn, State, _Extra) ->
handle_request(Request) -> handle_request(Request) ->
timer:kill_after(500), timer:kill_after(500),
exit(map_request(Request)). exit({response, map_request(Request)}).
map_request({ping, 'NULL'}) -> map_request({ping, 'NULL'}) ->
{response, {ack, 'NULL'}}; {ack, 'NULL'};
map_request({listActivities, 'NULL'}) -> map_request({listActivities, 'NULL'}) ->
{activities, Activities} = subject_router:get_activities(), {activities, Activities} = subject_router:get_activities(),
SortedActivities = lists:reverse(lists:keysort(3, Activities)), {activities,
{response, {activities,
[{'Activity', Subject, Type, round(Priority * 100)} [{'Activity', Subject, Type, round(Priority * 100)}
|| {Subject, Type, Priority} <- SortedActivities]}}; || {Subject, Type, Priority} <- Activities]};
map_request({logSession, {'Session', Subject, Type, Timestamp, Minutes}}) -> map_request({logSession, {'Session', Subject, Type, Timestamp, Minutes}}) ->
Session = {unicode:characters_to_list(Subject), Session = {unicode:characters_to_list(Subject),
Type, Timestamp, Minutes}, Type, Timestamp, Minutes},
case subject_router:log_session(Session) of case subject_router:log_session(Session) of
ok -> {response, {ack, 'NULL'}}; ok -> {ack, 'NULL'};
{error, _Error} -> {response, {error, invalidArguments}} {error, _Error} -> {error, invalidArguments}
end; end;
map_request(_) -> map_request(_) ->
{response, {error, invalidArguments}}. {error, invalidArguments}.
send(Socket, TransactionId, Response) -> send(Socket, TransactionId, Response) ->
{ok, Encoded} = 'StudySystemProtocol':encode( {ok, Encoded} = 'StudySystemProtocol':encode(

View File

@ -41,8 +41,7 @@ handle_info(accept, State = #state{socket = Socket}) ->
handle_info({handshake, TlsSocket}, State) -> handle_info({handshake, TlsSocket}, State) ->
case ssl:handshake(TlsSocket, 5000) of case ssl:handshake(TlsSocket, 5000) of
{ok, ClientSocket} -> {ok, ClientSocket} ->
{ok, Pid} = session_sup:start_session(ClientSocket), {ok, _Pid} = session_sup:start_session(ClientSocket);
ok = ssl:controlling_process(ClientSocket, Pid);
{error, _Reason} -> {error, _Reason} ->
ok ok
end, end,