Changes
16 changed files (+609/-623)
-
-
@@ -15,111 +15,109 @@ //// SPDX-License-Identifier: Apache-2.0 namespace Plac { namespace V2 { public errordomain RequestError { NETWORK_ERROR, UNEXPECTED_RESPONSE, } // TODO: Handle connection close and reconnect public class Connection : Object { private size_t request_id = 1; public errordomain RequestError { NETWORK_ERROR, UNEXPECTED_RESPONSE, } public Soup.WebsocketConnection conn { get; construct; } public Server server { get; construct; } public string token { get; construct; } // TODO: Handle connection close and reconnect public class Connection : Object { private size_t request_id = 1; public Connection(Soup.WebsocketConnection conn, Server server, string token) { Object(conn: conn, server: server, token: token); } public Soup.WebsocketConnection conn { get; construct; } public Server server { get; construct; } public string token { get; construct; } construct { // Roon forces application-layer ping/pong instead of WebSocket's // ping/pong frame. conn.message.connect((c, type, bytes) => { var message = (string) bytes.get_data(); public Connection(Soup.WebsocketConnection conn, Server server, string token) { Object(conn: conn, server: server, token: token); } Moo.Metadata req_meta; Moo.Headers req_headers; try { req_meta = new Moo.Metadata.from_string(message); req_headers = new Moo.Headers.from_string(message, req_meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } construct { // Roon forces application-layer ping/pong instead of WebSocket's // ping/pong frame. conn.message.connect((c, type, bytes) => { var message = (string) bytes.get_data(); if (req_meta.service != "com.roonlabs.ping:1/ping") { return; } Moo.Metadata req_meta; Moo.Headers req_headers; try { req_meta = new Moo.Metadata.from_string(message); req_headers = new Moo.Headers.from_string(message, req_meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } var res_meta = new Moo.Metadata("COMPLETE", "Success"); var res_headers = new Moo.Headers(); res_headers.write("Request-Id", @"$(req_headers.request_id)"); if (req_meta.service != "com.roonlabs.ping:1/ping") { return; } conn.send_binary(@"$res_meta$res_headers".data); }); } var res_meta = new Moo.Metadata("COMPLETE", "Success"); var res_headers = new Moo.Headers(); res_headers.write("Request-Id", @"$(req_headers.request_id)"); public async Response json_request(string service, string body) throws RequestError { SourceFunc callback = json_request.callback; conn.send_binary(@"$res_meta$res_headers".data); }); } var req_id = request_id; request_id += 1; public async Response json_request(string service, string body) throws RequestError { SourceFunc callback = json_request.callback; Response? resp = null; GLib.Error? error = null; var req_id = request_id; request_id += 1; var message_handler_id = conn.message.connect((c, type, bytes) => { var message = (string) bytes.get_data(); Response? resp = null; GLib.Error? error = null; Moo.Metadata res_meta; Moo.Headers res_headers; try { res_meta = new Moo.Metadata.from_string(message); res_headers = new Moo.Headers.from_string(message, res_meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } var message_handler_id = conn.message.connect((c, type, bytes) => { var message = (string) bytes.get_data(); if (res_meta.verb == "REQUEST" || res_headers.request_id != req_id) { return; } Moo.Metadata res_meta; Moo.Headers res_headers; try { res_meta = new Moo.Metadata.from_string(message); res_headers = new Moo.Headers.from_string(message, res_meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } resp = new Response(res_meta, res_headers, message); callback(); }); if (res_meta.verb == "REQUEST" || res_headers.request_id != req_id) { return; } var error_handler_id = conn.error.connect((c, error) => { GLib.log("Plac", LEVEL_ERROR, "Failed to receive MOO message: %s", error.message); callback(); }); resp = new Response(res_meta, res_headers, message); callback(); }); var req_meta = new Moo.Metadata("REQUEST", service); var req_headers = new Moo.Headers(); req_headers.write("Request-Id", @"$req_id"); var error_handler_id = conn.error.connect((c, error) => { GLib.log("Plac", LEVEL_ERROR, "Failed to receive MOO message: %s", error.message); callback(); }); req_headers.write("Content-Type", "application/json"); req_headers.write("Content-Length", @"$(body.length)"); var req_meta = new Moo.Metadata("REQUEST", service); var req_headers = new Moo.Headers(); req_headers.write("Request-Id", @"$req_id"); conn.send_binary(@"$req_meta$req_headers$body".data); req_headers.write("Content-Type", "application/json"); req_headers.write("Content-Length", @"$(body.length)"); yield; conn.send_binary(@"$req_meta$req_headers$body".data); conn.disconnect(message_handler_id); conn.disconnect(error_handler_id); yield; if (error != null) { throw new RequestError.NETWORK_ERROR(error.message); } conn.disconnect(message_handler_id); conn.disconnect(error_handler_id); if (resp == null) { throw new RequestError.UNEXPECTED_RESPONSE("Got unexpected response from Roon server, failed to parse."); } if (error != null) { throw new RequestError.NETWORK_ERROR(error.message); } return resp; if (resp == null) { throw new RequestError.UNEXPECTED_RESPONSE("Got unexpected response from Roon server, failed to parse."); } return resp; } } }
-
-
-
@@ -15,86 +15,84 @@ //// SPDX-License-Identifier: Apache-2.0 namespace Plac { namespace V2 { namespace Label { public abstract class Node : Object { public string text { get; construct; } } namespace Label { public abstract class Node : Object { public string text { get; construct; } } public class Text : Node { public Text(string text) { Object(text: text); } public class Text : Node { public Text(string text) { Object(text: text); } } public class Link : Node { public string id { get; construct; } public class Link : Node { public string id { get; construct; } public Link(string id, string text) { Object(id: id, text: text); } public Link(string id, string text) { Object(id: id, text: text); } } public class Parsed : Object { public GLib.Array<Node>nodes { get; construct; } public class Parsed : Object { public GLib.Array<Node>nodes { get; construct; } public string label { owned get { var builder = new GLib.StringBuilder(); foreach (var node in nodes.data) { builder.append(node.text); } return builder.free_and_steal(); public string label { owned get { var builder = new GLib.StringBuilder(); foreach (var node in nodes.data) { builder.append(node.text); } } public Parsed.from_string(string input) { Object(nodes: parse(input)); return builder.free_and_steal(); } } private static GLib.Array<Node>parse(string input) { var nodes = new GLib.Array<Node>(); public Parsed.from_string(string input) { Object(nodes: parse(input)); } int i = 0; while (i < input.length) { var link_start = input.index_of("[[", i); if (link_start < 0) { var node = new Text(input.slice(i, input.length)); nodes.append_val(node); break; } private static GLib.Array<Node>parse(string input) { var nodes = new GLib.Array<Node>(); var link_separator = input.index_of_char('|', link_start); if (link_separator < 0) { var node = new Text(input.slice(i, input.length)); nodes.append_val(node); break; } int i = 0; while (i < input.length) { var link_start = input.index_of("[[", i); if (link_start < 0) { var node = new Text(input.slice(i, input.length)); nodes.append_val(node); break; } var link_end = input.index_of("]]", link_separator); if (link_end < 0) { var node = new Text(input.slice(i, input.length)); nodes.append_val(node); break; } var link_separator = input.index_of_char('|', link_start); if (link_separator < 0) { var node = new Text(input.slice(i, input.length)); nodes.append_val(node); break; } if (link_start > i) { var text = new Text(input.slice(i, link_start)); nodes.append_val(text); } var link_end = input.index_of("]]", link_separator); if (link_end < 0) { var node = new Text(input.slice(i, input.length)); nodes.append_val(node); break; } var id = input.slice(link_start + 2, link_separator); var label = input.slice(link_separator + 1, link_end); if (link_start > i) { var text = new Text(input.slice(i, link_start)); nodes.append_val(text); } var link = new Link(id, label); nodes.append_val(link); var id = input.slice(link_start + 2, link_separator); var label = input.slice(link_separator + 1, link_end); i = link_end + 2; } var link = new Link(id, label); nodes.append_val(link); return nodes; i = link_end + 2; } return nodes; } } }
-
-
-
@@ -15,15 +15,13 @@ //// SPDX-License-Identifier: Apache-2.0 namespace Plac { namespace V2 { public class Response : Object { public Moo.Metadata meta { get; construct; } public Moo.Headers headers { get; construct; } public string message { get; construct; } public class Response : Object { public Moo.Metadata meta { get; construct; } public Moo.Headers headers { get; construct; } public string message { get; construct; } public Response(Moo.Metadata meta, Moo.Headers headers, string message) { Object(meta: meta, headers: headers, message: message); } public Response(Moo.Metadata meta, Moo.Headers headers, string message) { Object(meta: meta, headers: headers, message: message); } } }
-
-
-
@@ -15,232 +15,230 @@ //// SPDX-License-Identifier: Apache-2.0 namespace Plac { namespace V2 { public errordomain ResolveServerError { SERVER_NOT_FOUND, SERVER_MISMATCH, NETWORK_ERROR, } public errordomain ResolveServerError { SERVER_NOT_FOUND, SERVER_MISMATCH, NETWORK_ERROR, } public errordomain ConnectError { SERVER_NOT_FOUND, INVALID_REQUEST, INVALID_RESPONSE, SERVER_MISMATCH, NETWORK_ERROR, } public errordomain ConnectError { SERVER_NOT_FOUND, INVALID_REQUEST, INVALID_RESPONSE, SERVER_MISMATCH, NETWORK_ERROR, } public class Server : Object { /** * IP address of the server. */ public GLib.InetSocketAddress address { get; construct; } public class Server : Object { /** * IP address of the server. */ public GLib.InetSocketAddress address { get; construct; } /** * TCP port for WebSocket and HTTP connection. */ public uint16 http_port { get; construct; } /** * TCP port for WebSocket and HTTP connection. */ public uint16 http_port { get; construct; } /** * String uniquely identifies a server among servers in a network. */ public string id { get; construct; } /** * String uniquely identifies a server among servers in a network. */ public string id { get; construct; } public string version { get; construct; } public string version { get; construct; } /** * User-facing display name. */ public string name { get; construct; } /** * User-facing display name. */ public string name { get; construct; } public Server(GLib.InetSocketAddress address, string id, string version, string name, uint16 http_port) { Object(address: address, id: id, version: version, name: name, http_port: http_port); public Server(GLib.InetSocketAddress address, string id, string version, string name, uint16 http_port) { Object(address: address, id: id, version: version, name: name, http_port: http_port); } public static async Server from_address( GLib.InetSocketAddress address, uint16 http_port, string id, GLib.Cancellable? cancellable = null ) throws ResolveServerError { Soup.WebsocketConnection conn; try { conn = yield ws_connect(address, http_port, cancellable); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_DEBUG, "Handshake error"); throw new ResolveServerError.NETWORK_ERROR(error.message); } public static async Server from_address( GLib.InetSocketAddress address, uint16 http_port, string id, GLib.Cancellable? cancellable = null ) throws ResolveServerError { Soup.WebsocketConnection conn; try { conn = yield ws_connect(address, http_port, cancellable); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_DEBUG, "Handshake error"); throw new ResolveServerError.NETWORK_ERROR(error.message); } Roon.Registry.Info.Response info; try { info = yield get_registry_info(conn); } catch (ConnectError error) { throw new ResolveServerError.SERVER_NOT_FOUND(error.message); } Roon.Registry.Info.Response info; try { info = yield get_registry_info(conn); } catch (ConnectError error) { throw new ResolveServerError.SERVER_NOT_FOUND(error.message); } if (info.core_id != id) { throw new ResolveServerError.SERVER_MISMATCH("Server at the location has different server ID."); } return new Server(address, id, info.display_version, info.display_name, http_port); if (info.core_id != id) { throw new ResolveServerError.SERVER_MISMATCH("Server at the location has different server ID."); } private static async Soup.WebsocketConnection ws_connect( GLib.InetSocketAddress address, uint16 port, GLib.Cancellable? cancellable = null ) throws GLib.Error { var session_singleton = new Session(); var session = session_singleton.session; return new Server(address, id, info.display_version, info.display_name, http_port); } var url = GLib.Uri.build( NONE, "ws:", null, address.address.to_string(), port, "/api", null, null ); var msg = new Soup.Message.from_uri("GET", url); private static async Soup.WebsocketConnection ws_connect( GLib.InetSocketAddress address, uint16 port, GLib.Cancellable? cancellable = null ) throws GLib.Error { var session_singleton = new Session(); var session = session_singleton.session; // Roon API does not specify WebSocket subprotocols. Soup.WebsocketConnection conn = yield session.websocket_connect_async( msg, null, null, GLib.Priority.DEFAULT, cancellable ); var url = GLib.Uri.build( NONE, "ws:", null, address.address.to_string(), port, "/api", null, null ); var msg = new Soup.Message.from_uri("GET", url); return conn; } // Roon API does not specify WebSocket subprotocols. Soup.WebsocketConnection conn = yield session.websocket_connect_async( msg, null, null, GLib.Priority.DEFAULT, cancellable ); private static async Response send_request( Soup.WebsocketConnection conn, string service, uint64 request_id, string? json_body = null ) throws ConnectError { SourceFunc callback = send_request.callback; Response? resp = null; return conn; } var message_handler_id = conn.message.connect((c, type, bytes) => { var message = (string) bytes.get_data(); private static async Response send_request( Soup.WebsocketConnection conn, string service, uint64 request_id, string? json_body = null ) throws ConnectError { SourceFunc callback = send_request.callback; Response? resp = null; Moo.Metadata res_meta; Moo.Headers res_headers; try { res_meta = new Moo.Metadata.from_string(message); res_headers = new Moo.Headers.from_string(message, res_meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } var message_handler_id = conn.message.connect((c, type, bytes) => { var message = (string) bytes.get_data(); if (res_headers.request_id != request_id) { GLib.log("Plac", LEVEL_DEBUG, "Ignoring response with unexpected Request-Id"); return; } Moo.Metadata res_meta; Moo.Headers res_headers; try { res_meta = new Moo.Metadata.from_string(message); res_headers = new Moo.Headers.from_string(message, res_meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } resp = new Response(res_meta, res_headers, message); callback(); }); if (res_headers.request_id != request_id) { GLib.log("Plac", LEVEL_DEBUG, "Ignoring response with unexpected Request-Id"); return; } var error_handler_id = conn.error.connect((c, error) => { GLib.log("Plac", LEVEL_ERROR, "Failed to receive MOO message: %s", error.message); callback(); }); resp = new Response(res_meta, res_headers, message); callback(); }); var req_meta = new Moo.Metadata("REQUEST", service); var req_headers = new Moo.Headers(); req_headers.write("Request-Id", @"$request_id"); var error_handler_id = conn.error.connect((c, error) => { GLib.log("Plac", LEVEL_ERROR, "Failed to receive MOO message: %s", error.message); callback(); }); if (json_body != null) { req_headers.write("Content-Type", "application/json"); req_headers.write("Content-Length", @"$(json_body.length)"); var req_meta = new Moo.Metadata("REQUEST", service); var req_headers = new Moo.Headers(); req_headers.write("Request-Id", @"$request_id"); conn.send_binary(@"$req_meta$req_headers$json_body".data); } else { conn.send_binary(@"$req_meta$req_headers".data); } if (json_body != null) { req_headers.write("Content-Type", "application/json"); req_headers.write("Content-Length", @"$(json_body.length)"); yield; conn.send_binary(@"$req_meta$req_headers$json_body".data); } else { conn.send_binary(@"$req_meta$req_headers".data); } conn.disconnect(message_handler_id); conn.disconnect(error_handler_id); yield; if (resp == null) { throw new ConnectError.SERVER_NOT_FOUND("Server not found."); } conn.disconnect(message_handler_id); conn.disconnect(error_handler_id); return resp; if (resp == null) { throw new ConnectError.SERVER_NOT_FOUND("Server not found."); } private static async Roon.Registry.Info.Response get_registry_info( Soup.WebsocketConnection conn, uint64 request_id = 1 ) throws ConnectError { var resp = yield send_request(conn, "com.roonlabs.registry:1/info", request_id); return resp; } if (resp.meta.service != "Success") { throw new ConnectError.INVALID_RESPONSE("Server error."); } private static async Roon.Registry.Info.Response get_registry_info( Soup.WebsocketConnection conn, uint64 request_id = 1 ) throws ConnectError { var resp = yield send_request(conn, "com.roonlabs.registry:1/info", request_id); if (resp.headers.content_type != "application/json") { throw new ConnectError.INVALID_RESPONSE("Not a JSON body."); } if (resp.meta.service != "Success") { throw new ConnectError.INVALID_RESPONSE("Server error."); } Roon.Registry.Info.Response info; try { var body = new Moo.JsonBody.from_string(resp.message, resp.headers); if (resp.headers.content_type != "application/json") { throw new ConnectError.INVALID_RESPONSE("Not a JSON body."); } info = new Roon.Registry.Info.Response.from_json(body.data); } catch (GLib.Error error) { throw new ConnectError.INVALID_RESPONSE(error.message); } Roon.Registry.Info.Response info; try { var body = new Moo.JsonBody.from_string(resp.message, resp.headers); if (info == null) { throw new ConnectError.INVALID_RESPONSE("Unable to parse info response: Out of memory."); } info = new Roon.Registry.Info.Response.from_json(body.data); } catch (GLib.Error error) { throw new ConnectError.INVALID_RESPONSE(error.message); } return info; if (info == null) { throw new ConnectError.INVALID_RESPONSE("Unable to parse info response: Out of memory."); } public async Connection connect_async(Roon.Registry.Register.Request req) throws ConnectError { GLib.log("Plac", LEVEL_DEBUG, @"Connecting to $(this.address.address):$(this.http_port)"); return info; } Soup.WebsocketConnection conn; try { conn = yield ws_connect(address, http_port); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_DEBUG, "Handshake error"); throw new ConnectError.NETWORK_ERROR(error.message); } public async Connection connect_async(Roon.Registry.Register.Request req) throws ConnectError { GLib.log("Plac", LEVEL_DEBUG, @"Connecting to $(this.address.address):$(this.http_port)"); var info = yield get_registry_info(conn, 1); Soup.WebsocketConnection conn; try { conn = yield ws_connect(address, http_port); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_DEBUG, "Handshake error"); throw new ConnectError.NETWORK_ERROR(error.message); } if (info.core_id != id) { throw new ConnectError.SERVER_MISMATCH("Got unexpected server ID."); } var info = yield get_registry_info(conn, 1); if (info.core_id != id) { throw new ConnectError.SERVER_MISMATCH("Got unexpected server ID."); } GLib.log("Plac", LEVEL_DEBUG, "Confirmed the Roon server is running. Registering extension."); GLib.log("Plac", LEVEL_DEBUG, "Confirmed the Roon server is running. Registering extension."); var req_json = req.to_json(); if (req_json == null) { throw new ConnectError.INVALID_REQUEST("Unable to serialize register request."); } var req_json = req.to_json(); if (req_json == null) { throw new ConnectError.INVALID_REQUEST("Unable to serialize register request."); } var register_resp = yield send_request(conn, "com.roonlabs.registry:1/register", 2, req_json); var register_resp = yield send_request(conn, "com.roonlabs.registry:1/register", 2, req_json); if (register_resp.meta.service != "Registered") { throw new ConnectError.INVALID_RESPONSE(@"$(register_resp.meta.service)"); } if (register_resp.meta.service != "Registered") { throw new ConnectError.INVALID_RESPONSE(@"$(register_resp.meta.service)"); } Roon.Registry.Register.Response result; try { var body = new Moo.JsonBody.from_string(register_resp.message, register_resp.headers); Roon.Registry.Register.Response result; try { var body = new Moo.JsonBody.from_string(register_resp.message, register_resp.headers); result = new Roon.Registry.Register.Response.from_json(body.data); } catch (GLib.Error error) { throw new ConnectError.INVALID_RESPONSE(error.message); } result = new Roon.Registry.Register.Response.from_json(body.data); } catch (GLib.Error error) { throw new ConnectError.INVALID_RESPONSE(error.message); } if (result == null) { throw new ConnectError.INVALID_RESPONSE("Unable to parse info response: Out of memory."); } if (result == null) { throw new ConnectError.INVALID_RESPONSE("Unable to parse info response: Out of memory."); } GLib.log("Plac", LEVEL_DEBUG, @"Registered Roon extension at $(this.address.address):$(this.http_port)"); GLib.log("Plac", LEVEL_DEBUG, @"Registered Roon extension at $(this.address.address):$(this.http_port)"); return new Connection(conn, this, result.token); } return new Connection(conn, this, result.token); } } }
-
-
-
@@ -15,138 +15,136 @@ //// SPDX-License-Identifier: Apache-2.0 namespace Plac { namespace V2 { public class ServerScanner : Object { /** * Found a server. * * ServerScanner emits this signal everytime server returned discovery response: * if a server A returned a response at t1 then returned again at t2, ServerScanner * emits this signal twice (t1 and t2.) */ public signal void found(Server server); public class ServerScanner : Object { /** * Found a server. * * ServerScanner emits this signal everytime server returned discovery response: * if a server A returned a response at t1 then returned again at t2, ServerScanner * emits this signal twice (t1 and t2.) */ public signal void found(Server server); /** * Scan has been aborted due to a socket error. */ public signal void scan_failed(GLib.Error error); private GLib.Cancellable? cancellable = null; public int64 read_timeout_us = 1000 * 1000 * 2; public ServerScanner() { Object(); } /** * Scan has been aborted due to a socket error. */ public signal void scan_failed(GLib.Error error); public void start() { GLib.log("Plac", LEVEL_DEBUG, "Starting server scanner..."); private GLib.Cancellable? cancellable = null; cancellable = new GLib.Cancellable(); public int64 read_timeout_us = 1000 * 1000 * 2; new GLib.Thread<void>("plac-server-scanner", () => { try { var socket = new GLib.Socket(IPV4, DATAGRAM, UDP); public ServerScanner() { Object(); } socket.set_timeout(3); public void start() { GLib.log("Plac", LEVEL_DEBUG, "Starting server scanner..."); var inet_addr = new GLib.InetAddress.from_bytes(Sood.DISCOVERY_MULTICAST_IPV4_ADDRESS, IPV4); var sock_addr = new GLib.InetSocketAddress(inet_addr, Sood.DISCOVERY_SERVER_UDP_PORT); cancellable = new GLib.Cancellable(); while (true) { try { socket.send_to(sock_addr, (uint8[]) Sood.DISCOVERY_QUERY_PREBUILT, cancellable); new GLib.Thread<void>("plac-server-scanner", () => { try { var socket = new GLib.Socket(IPV4, DATAGRAM, UDP); GLib.log("Plac", LEVEL_DEBUG, "Sent discovery query"); } catch (GLib.Error error) { if (error is GLib.IOError.CANCELLED) { return; } socket.set_timeout(3); GLib.log("Plac", LEVEL_CRITICAL, "Failed to send discovery query: %s", error.message); var inet_addr = new GLib.InetAddress.from_bytes(Sood.DISCOVERY_MULTICAST_IPV4_ADDRESS, IPV4); var sock_addr = new GLib.InetSocketAddress(inet_addr, Sood.DISCOVERY_SERVER_UDP_PORT); var e = error.copy(); GLib.Idle.add(() => { scan_failed(e); return false; }); cancellable = null; return; } while (true) { try { socket.send_to(sock_addr, (uint8[]) Sood.DISCOVERY_QUERY_PREBUILT, cancellable); GLib.SocketAddress addr; var bytes = socket.receive_bytes_from(out addr, 512, read_timeout_us, cancellable); if (!(addr is GLib.InetSocketAddress)) { GLib.log("Plac", LEVEL_WARNING, "Received discovery response from non-IP socket"); continue; } Sood.DiscoveryResponse resp; var result = Sood.DiscoveryResponse.parse(out resp, (char[]) bytes.get_data()); if (result != Sood.Result.OK) { GLib.log("Plac", LEVEL_WARNING, "Received malformed discovery response: %s", result.to_string()); continue; } GLib.log("Plac", LEVEL_DEBUG, "Sent discovery query"); // String in Vala is null-terminated, without exception. // Using non null-terminated string or casting uint8[] to string // will result in out of bound reads. I found no function, class, // or language builtins for allocating null-terminated string from // non-null one. Slicing is the closest I got. var server = new Server( (GLib.InetSocketAddress) addr, resp.unique_id.slice(0, (long) resp.unique_id_len), resp.display_version.slice(0, (long) resp.display_version_len), resp.name.slice(0, (long) resp.name_len), resp.http_port ); GLib.Idle.add(() => { found(server); return false; }); } catch (GLib.Error error) { if (error is GLib.IOError.TIMED_OUT) { break; } if (error is GLib.IOError.CANCELLED) { return; } GLib.log("Plac", LEVEL_CRITICAL, "Failed to send discovery query: %s", error.message); GLib.log("Plac", LEVEL_CRITICAL, "Failed to read discovery response: %s", error.message); cancellable = null; var e = error.copy(); GLib.Idle.add(() => { scan_failed(e); return false; }); cancellable = null; return; } while (true) { try { GLib.SocketAddress addr; var bytes = socket.receive_bytes_from(out addr, 512, read_timeout_us, cancellable); if (!(addr is GLib.InetSocketAddress)) { GLib.log("Plac", LEVEL_WARNING, "Received discovery response from non-IP socket"); continue; } Sood.DiscoveryResponse resp; var result = Sood.DiscoveryResponse.parse(out resp, (char[]) bytes.get_data()); if (result != Sood.Result.OK) { GLib.log("Plac", LEVEL_WARNING, "Received malformed discovery response: %s", result.to_string()); continue; } // String in Vala is null-terminated, without exception. // Using non null-terminated string or casting uint8[] to string // will result in out of bound reads. I found no function, class, // or language builtins for allocating null-terminated string from // non-null one. Slicing is the closest I got. var server = new Server( (GLib.InetSocketAddress) addr, resp.unique_id.slice(0, (long) resp.unique_id_len), resp.display_version.slice(0, (long) resp.display_version_len), resp.name.slice(0, (long) resp.name_len), resp.http_port ); GLib.Idle.add(() => { found(server); return false; }); } catch (GLib.Error error) { if (error is GLib.IOError.TIMED_OUT) { break; } if (error is GLib.IOError.CANCELLED) { return; } GLib.log("Plac", LEVEL_CRITICAL, "Failed to read discovery response: %s", error.message); cancellable = null; var e = error.copy(); GLib.Idle.add(() => { scan_failed(e); return false; }); return; } } } } catch (GLib.Error error) { GLib.log("Plac", LEVEL_CRITICAL, "Discovery aborted due to an error: %s", error.message); var e = error.copy(); GLib.Idle.add(() => { scan_failed(e); return false; }); } } catch (GLib.Error error) { GLib.log("Plac", LEVEL_CRITICAL, "Discovery aborted due to an error: %s", error.message); cancellable = null; }); } var e = error.copy(); GLib.Idle.add(() => { scan_failed(e); return false; }); } cancellable = null; }); } public void stop() { GLib.log("Plac", LEVEL_DEBUG, "Stopping server scanner..."); cancellable.cancel(); } public void stop() { GLib.log("Plac", LEVEL_DEBUG, "Stopping server scanner..."); cancellable.cancel(); } } }
-
-
-
@@ -15,16 +15,14 @@ //// SPDX-License-Identifier: Apache-2.0 namespace Plac { namespace V2 { [SingleInstance] private class Session : Object { public Soup.Session session = new Soup.Session.with_options( "user_agent", "plac-for-gtk4/0.0 " ); [SingleInstance] private class Session : Object { public Soup.Session session = new Soup.Session.with_options( "user_agent", "plac-for-gtk4/0.0 " ); public Session() { Object(); } public Session() { Object(); } } }
-
-
-
@@ -15,195 +15,193 @@ //// SPDX-License-Identifier: Apache-2.0 namespace Plac { namespace V2 { public errordomain ZonesModelError { SUBSCRIPTION_FAILED, } public errordomain ZonesModelError { SUBSCRIPTION_FAILED, } public class ZoneModel : Object { public Roon.Transport.Zone zone { get; set construct; } public class ZoneModel : Object { public Roon.Transport.Zone zone { get; set construct; } public ZoneModel(Roon.Transport.Zone zone) { Object(zone: zone); } public ZoneModel(Roon.Transport.Zone zone) { Object(zone: zone); } public static bool is_same_zone(ZoneModel a, ZoneModel b) { return a.zone.zone_id == b.zone.zone_id; } public static bool is_same_zone(ZoneModel a, ZoneModel b) { return a.zone.zone_id == b.zone.zone_id; } } public class ZonesModel : Object { public signal void added(Roon.Transport.Zone zone); public signal void changed(Roon.Transport.Zone zone); public signal void removed(Roon.Transport.Zone zone); public signal void seek(Roon.Transport.SeekChange change); public signal void error(GLib.Error error); public class ZonesModel : Object { public signal void added(Roon.Transport.Zone zone); public signal void changed(Roon.Transport.Zone zone); public signal void removed(Roon.Transport.Zone zone); public signal void seek(Roon.Transport.SeekChange change); public signal void error(GLib.Error error); public Connection conn { get; set construct; } public Connection conn { get; set construct; } public GLib.ListStore zones { get; construct; public GLib.ListStore zones { get; construct; } private uint zone_position = 0; public ZoneModel? zone { owned get { return (ZoneModel?) zones.get_item(zone_position); } private uint zone_position = 0; public ZoneModel? zone { owned get { return (ZoneModel?) zones.get_item(zone_position); } set { uint position; if (zones.find_with_equal_func(value, (GLib.EqualFunc<ZoneModel>) ZoneModel.is_same_zone, out position)) { zone_position = position; } set { uint position; if (zones.find_with_equal_func(value, (GLib.EqualFunc<ZoneModel>) ZoneModel.is_same_zone, out position)) { zone_position = position; } } } public ZonesModel(Connection conn) { Object(conn: conn, zones: new GLib.ListStore(typeof (ZoneModel))); } public ZonesModel(Connection conn) { Object(conn: conn, zones: new GLib.ListStore(typeof (ZoneModel))); } construct { this.notify["conn"].connect(() => { this.listen_changes(); }); construct { this.notify["conn"].connect(() => { this.listen_changes(); }); this.listen_changes(); } this.listen_changes(); } private void listen_changes() { this.zones.remove_all(); private void listen_changes() { this.zones.remove_all(); this.listen_changes_async.begin((obj, res) => { try { this.listen_changes_async.end(res); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_CRITICAL, "Failed to subscribe to zone changes: %s", error.message); } }); } this.listen_changes_async.begin((obj, res) => { try { this.listen_changes_async.end(res); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_CRITICAL, "Failed to subscribe to zone changes: %s", error.message); } }); } private async void listen_changes_async() throws GLib.Error { GLib.log("Plac", LEVEL_DEBUG, "Subscribing to zone changes..."); private async void listen_changes_async() throws GLib.Error { GLib.log("Plac", LEVEL_DEBUG, "Subscribing to zone changes..."); var req = new Roon.Transport.SubscribeZoneChanges.Request("1"); var req = new Roon.Transport.SubscribeZoneChanges.Request("1"); var res = yield conn.json_request("com.roonlabs.transport:2/subscribe_zones", req.to_json()); if (res.meta.service != "Subscribed") { throw new ZonesModelError.SUBSCRIPTION_FAILED(@"Expected Subscribed, got \"$(res.meta.service)\""); } var res = yield conn.json_request("com.roonlabs.transport:2/subscribe_zones", req.to_json()); if (res.meta.service != "Subscribed") { throw new ZonesModelError.SUBSCRIPTION_FAILED(@"Expected Subscribed, got \"$(res.meta.service)\""); } var body = new Moo.JsonBody.from_string(res.message, res.headers); var result = new Roon.Transport.SubscribeZoneChanges.Response.from_json(body.data); var body = new Moo.JsonBody.from_string(res.message, res.headers); var result = new Roon.Transport.SubscribeZoneChanges.Response.from_json(body.data); if (result == null) { throw new ZonesModelError.SUBSCRIPTION_FAILED("Got invalid subscription response."); } if (result == null) { throw new ZonesModelError.SUBSCRIPTION_FAILED("Got invalid subscription response."); } foreach (var zone in result.zones) { var model = new ZoneModel(zone); zones.append(model); if (this.zone == null) { this.zone = model; } foreach (var zone in result.zones) { var model = new ZoneModel(zone); zones.append(model); if (this.zone == null) { this.zone = model; } } GLib.log("Plac", LEVEL_DEBUG, "Subscribed successfully, found %u initial.", zones.n_items); GLib.log("Plac", LEVEL_DEBUG, "Subscribed successfully, found %u initial.", zones.n_items); conn.conn.message.connect((_conn, _type, bytes) => { var message = (string) bytes.get_data(); conn.conn.message.connect((_conn, _type, bytes) => { var message = (string) bytes.get_data(); Moo.Metadata meta; Moo.Headers headers; try { meta = new Moo.Metadata.from_string(message); headers = new Moo.Headers.from_string(message, meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } Moo.Metadata meta; Moo.Headers headers; try { meta = new Moo.Metadata.from_string(message); headers = new Moo.Headers.from_string(message, meta); } catch (GLib.Error error) { GLib.log("Plac", LEVEL_WARNING, "Got invalid MOO message: %s", error.message); return; } if (meta.service != "Changed" || headers.request_id != res.headers.request_id) { return; } if (meta.service != "Changed" || headers.request_id != res.headers.request_id) { return; } Moo.JsonBody event_body; try { event_body = new Moo.JsonBody.from_string(message, headers); } catch (Moo.JsonBodyParseError error) { GLib.log("Plac", LEVEL_WARNING, "Got zone change event, but payload is unparsable."); return; } var event = new Roon.Transport.SubscribeZoneChanges.Event.from_json(event_body.data); if (event == null) { GLib.log("Plac", LEVEL_WARNING, "Got unexpected zone change event."); return; } Moo.JsonBody event_body; try { event_body = new Moo.JsonBody.from_string(message, headers); } catch (Moo.JsonBodyParseError error) { GLib.log("Plac", LEVEL_WARNING, "Got zone change event, but payload is unparsable."); return; } var event = new Roon.Transport.SubscribeZoneChanges.Event.from_json(event_body.data); if (event == null) { GLib.log("Plac", LEVEL_WARNING, "Got unexpected zone change event."); return; } foreach (var id in event.removed_zone_ids) { for (uint i = 0; i < zones.n_items; i++) { var model = (ZoneModel) zones.get_item(i); if (model.zone.zone_id == id) { GLib.log("Plac", LEVEL_DEBUG, "Zone removed: %s", model.zone.display_name); zones.remove(i); this.removed(model.zone); break; } foreach (var id in event.removed_zone_ids) { for (uint i = 0; i < zones.n_items; i++) { var model = (ZoneModel) zones.get_item(i); if (model.zone.zone_id == id) { GLib.log("Plac", LEVEL_DEBUG, "Zone removed: %s", model.zone.display_name); zones.remove(i); this.removed(model.zone); break; } } } foreach (var added in event.added_zones) { var new_model = new ZoneModel(added); foreach (var added in event.added_zones) { var new_model = new ZoneModel(added); // Roon behaves pretty badly in state updates: they send // an "added" event initially. Subscribe method's response // already contains those zones. uint position; if (zones.find_with_equal_func(new_model, (GLib.EqualFunc<ZoneModel>) ZoneModel.is_same_zone, out position)) { var model = (ZoneModel) zones.get_item(position); model.zone = added; this.changed(added); } else { GLib.log("Plac", LEVEL_DEBUG, "New zone added: %s", added.display_name); zones.append(new_model); this.added(added); } // Roon behaves pretty badly in state updates: they send // an "added" event initially. Subscribe method's response // already contains those zones. uint position; if (zones.find_with_equal_func(new_model, (GLib.EqualFunc<ZoneModel>) ZoneModel.is_same_zone, out position)) { var model = (ZoneModel) zones.get_item(position); model.zone = added; this.changed(added); } else { GLib.log("Plac", LEVEL_DEBUG, "New zone added: %s", added.display_name); zones.append(new_model); this.added(added); } } foreach (var changed in event.changed_zones) { var new_model = new ZoneModel(changed); foreach (var changed in event.changed_zones) { var new_model = new ZoneModel(changed); // We might have missed an "added" event, so make sure // we safely handle change events to unknown zones. uint position; if (zones.find_with_equal_func(new_model, (GLib.EqualFunc<ZoneModel>) ZoneModel.is_same_zone, out position)) { var model = (ZoneModel) zones.get_item(position); model.zone = changed; this.changed(changed); } else { GLib.log("Plac", LEVEL_DEBUG, "New zone added by change event: %s", changed.display_name); zones.append(new_model); this.added(changed); } // We might have missed an "added" event, so make sure // we safely handle change events to unknown zones. uint position; if (zones.find_with_equal_func(new_model, (GLib.EqualFunc<ZoneModel>) ZoneModel.is_same_zone, out position)) { var model = (ZoneModel) zones.get_item(position); model.zone = changed; this.changed(changed); } else { GLib.log("Plac", LEVEL_DEBUG, "New zone added by change event: %s", changed.display_name); zones.append(new_model); this.added(changed); } } foreach (var seek_change in event.seek_changes) { for (uint i = 0; i < zones.n_items; i++) { var model = (ZoneModel) zones.get_item(i); if (model.zone.zone_id == seek_change.zone_id && model.zone.now_playing != null) { if (seek_change.has_seek_position) { model.zone.now_playing.has_seek_info = true; model.zone.now_playing.seek_position = seek_change.seek_position; } else { model.zone.now_playing.has_seek_info = false; } this.seek(seek_change); break; foreach (var seek_change in event.seek_changes) { for (uint i = 0; i < zones.n_items; i++) { var model = (ZoneModel) zones.get_item(i); if (model.zone.zone_id == seek_change.zone_id && model.zone.now_playing != null) { if (seek_change.has_seek_position) { model.zone.now_playing.has_seek_info = true; model.zone.now_playing.seek_position = seek_change.seek_position; } else { model.zone.now_playing.has_seek_info = false; } this.seek(seek_change); break; } } }); } } }); } } }
-
-
-
@@ -54,7 +54,7 @@ height_request = (int) value;} } public Plac.V2.Connection? conn { get; set construct; } public Plac.Connection? conn { get; set construct; } public string? image_key { get; set construct; } public Roon.Image.ScalingMethod scaling { get; set; default = FIT; }
-
-
-
@@ -48,7 +48,7 @@ private unowned Adw.ToastOverlay toasts;private GLib.ListStore items_store = new GLib.ListStore(typeof (BrowseItem.Item)); private Plac.V2.Connection? conn = null; private Plac.Connection? conn = null; private BrowseItem.Item? item = null;
-
@@ -56,7 +56,7 @@ private bool is_loading = false;private Settings settings = new Settings(); public Roon.Browse.Hierarchy hierarchy { get; set; default = BROWSE; } public Plac.V2.ZonesModel? zones_model { get; set; default = null; } public Plac.ZonesModel? zones_model { get; set; default = null; } public Roon.Transport.Zone? zone { get { if (zones_model == null || zones_model.zone == null) {
-
@@ -113,7 +113,7 @@settings.settings.bind(Settings.SHOW_BROWSE_ITEM_SEPARATORS, items, "show-separators", GET); } public void start(Plac.V2.Connection? conn, Plac.V2.ZonesModel zones_model) { public void start(Plac.Connection? conn, Plac.ZonesModel zones_model) { this.conn = conn; this.zones_model = zones_model; load_page();
-
@@ -216,7 +216,7 @@var load_res = yield load_async(load_req); if (settings.label_parsing_enabled) { var parsed = new Plac.V2.Label.Parsed.from_string(load_res.list.title); var parsed = new Plac.Label.Parsed.from_string(load_res.list.title); title.label = parsed.label; } else { title.label = load_res.list.title;
-
@@ -224,7 +224,7 @@ }if (load_res.list.subtitle != null) { if (settings.label_parsing_enabled) { var parsed = new Plac.V2.Label.Parsed.from_string(load_res.list.subtitle); var parsed = new Plac.Label.Parsed.from_string(load_res.list.subtitle); subtitle.label = parsed.label; } else { subtitle.label = load_res.list.subtitle;
-
-
-
@@ -18,9 +18,9 @@ namespace PlacGtkAdwaita {namespace BrowseItem { class Item : Object { public Roon.Browse.Item item { get; construct; } public Plac.V2.Connection? conn { get; construct; } public Plac.Connection? conn { get; construct; } public Item(Roon.Browse.Item item, Plac.V2.Connection? conn = null) { public Item(Roon.Browse.Item item, Plac.Connection? conn = null) { Object(item: item, conn: conn); } }
-
-
-
@@ -34,9 +34,9 @@ public Roon.Browse.Item item { get; construct; }private Settings settings = new Settings(); public Plac.V2.Connection? conn { get; set construct; } public Plac.Connection? conn { get; set construct; } public Navigation(Roon.Browse.Item item, Plac.V2.Connection? conn = null) { public Navigation(Roon.Browse.Item item, Plac.Connection? conn = null) { Object(item: item, conn: conn); }
-
@@ -44,14 +44,14 @@ construct {this.bind_property("conn", artwork, "conn", SYNC_CREATE); if (settings.label_parsing_enabled) { var parsed = new Plac.V2.Label.Parsed.from_string(item.title); var parsed = new Plac.Label.Parsed.from_string(item.title); title.label = parsed.label; } else { title.label = item.title; } if (item.subtitle != null) { if (settings.label_parsing_enabled) { var parsed = new Plac.V2.Label.Parsed.from_string(item.subtitle); var parsed = new Plac.Label.Parsed.from_string(item.subtitle); subtitle.label = parsed.label; } else { subtitle.label = item.subtitle;
-
-
-
@@ -19,11 +19,11 @@ namespace BrowseItem {class Row : Gtk.ListBoxRow { public Roon.Browse.Item item { get; construct; } public Plac.V2.Connection? conn { public Plac.Connection? conn { get; construct; } public Row(Roon.Browse.Item item, Plac.V2.Connection? conn = null) { public Row(Roon.Browse.Item item, Plac.Connection? conn = null) { Object(item: item, conn: conn); }
-
-
-
@@ -70,9 +70,9 @@ private bool is_seeking = false;private int64? next_seek = null; private Settings settings = new Settings(); private Plac.V2.ZoneModel? watching_zone = null; private Plac.ZoneModel? watching_zone = null; private Plac.V2.ZoneModel? selected_zone { private Plac.ZoneModel? selected_zone { owned get { if (zones_model == null) { return null;
-
@@ -92,8 +92,8 @@ next_seek = null;} } public Plac.V2.Connection? conn { get; set construct; } public Plac.V2.ZonesModel? zones_model { get; set; default = null; } public Plac.Connection? conn { get; set construct; } public Plac.ZonesModel? zones_model { get; set; default = null; } public PlaybackToolbar() { Object();
-
@@ -135,14 +135,14 @@zone_list_factory.bind.connect((item) => { var list_item = (Gtk.ListItem) item; var label = (Gtk.Label) list_item.child; var model = (Plac.V2.ZoneModel) list_item.item; var model = (Plac.ZoneModel) list_item.item; label.label = model.zone.display_name; }); zone_list.factory = zone_list_factory; zone_list.notify["selected"].connect(() => { var model = (Plac.V2.ZoneModel) zones_model.zones.get_item(zone_list.selected); var model = (Plac.ZoneModel) zones_model.zones.get_item(zone_list.selected); if (model != null) { this.selected_zone = model; this.render();
-
@@ -425,7 +425,7 @@ seek.sensitive = true;} private async void control_async( Plac.V2.Connection conn, Roon.Transport.Zone zone, Roon.Transport.ControlType control Plac.Connection conn, Roon.Transport.Zone zone, Roon.Transport.ControlType control ) throws GLib.Error { var req = new Roon.Transport.Control.Request(zone.zone_id, control); var res = yield conn.json_request("com.roonlabs.transport:2/control", req.to_json());
-
@@ -436,7 +436,7 @@ }} private async void seek_async( Plac.V2.Connection conn, Roon.Transport.Zone zone, int64 seconds Plac.Connection conn, Roon.Transport.Zone zone, int64 seconds ) throws GLib.Error { var req = new Roon.Transport.Seek.Request(zone.zone_id); req.how = ABSOLUTE;
-
-
-
@@ -57,7 +57,7 @@ public string output_id {get { return _output.output_id; } } public Plac.V2.Connection? conn { get; set construct; } public Plac.Connection? conn { get; set construct; } private Roon.Transport.Output _output; public Roon.Transport.Output output {
-
@@ -187,7 +187,7 @@ take_volume_change();}); } private async void change_volume_async(Plac.V2.Connection conn, double value) throws GLib.Error { private async void change_volume_async(Plac.Connection conn, double value) throws GLib.Error { var req = new Roon.Transport.ChangeVolume.Request(output.output_id); req.how = ABSOLUTE; req.value = value;
-
@@ -199,7 +199,7 @@ }} private async void step_volume_async( Plac.V2.Connection conn, ZoneOutputRowVolumeStepDirection direction Plac.Connection conn, ZoneOutputRowVolumeStepDirection direction ) throws GLib.Error { double step = output.volume == null || output.volume.type == "incremental" ? 1.0 : output.volume.step;
-
-
-
@@ -39,13 +39,13 @@ private unowned Gtk.ListBox browse_hierarchy;private Settings settings = new Settings(); private Plac.V2.Server? server = null; private Plac.V2.Connection? conn = null; private Plac.V2.ZonesModel? zones = null; private Plac.Server? server = null; private Plac.Connection? conn = null; private Plac.ZonesModel? zones = null; private string server_id; public MainWindow(Gtk.Application app, Plac.V2.Server server) { public MainWindow(Gtk.Application app, Plac.Server server) { (typeof (Artwork)).ensure(); (typeof (ServerConnecting)).ensure(); (typeof (PlaybackToolbar)).ensure();
-
@@ -148,10 +148,10 @@var colon = ip_addr.index_of(":"); var patched_ip_addr = colon < 0 ? ip_addr : ip_addr.slice(0, colon); var address = new GLib.InetSocketAddress.from_string(patched_ip_addr, port); Plac.V2.Server.from_address.begin(address, port, server_id, null, (obj, res) => { Plac.Server.from_address.begin(address, port, server_id, null, (obj, res) => { try { this.server = Plac.V2.Server.from_address.end(res); } catch (Plac.V2.ResolveServerError error) { this.server = Plac.Server.from_address.end(res); } catch (Plac.ResolveServerError error) { GLib.log("Plac", LEVEL_DEBUG, "Failed to resolve: %s", error.message); GLib.log("Plac", LEVEL_INFO, "Failed to restore connection, scanning server"); try_listen(settings.connected_server_token);
-
@@ -186,7 +186,7 @@ server.connect_async.begin(req, (obj, res) => {try { var conn = server.connect_async.end(res); this.conn = conn; zones = new Plac.V2.ZonesModel(conn); zones = new Plac.ZonesModel(conn); root_stack.visible_child_name = "main"; playback_toolbar.visible = true;
-
@@ -199,7 +199,7 @@playback_toolbar.conn = conn; playback_toolbar.zones_model = zones; browse.start(conn, zones); } catch (Plac.V2.ConnectError error) { } catch (Plac.ConnectError error) { GLib.log("Plac", LEVEL_CRITICAL, "Failed to connect: %s", error.message); error_banner.title = "Connection error: %s".printf(error.message); error_banner.revealed = true;
-
@@ -211,7 +211,7 @@ private async void resolve_server() throws GLib.Error {GLib.SourceFunc callback = resolve_server.callback; GLib.Error? error = null; var scanner = new Plac.V2.ServerScanner(); var scanner = new Plac.ServerScanner(); scanner.start(); scanner.found.connect((found) => {
-
-
-
@@ -37,7 +37,7 @@ private unowned Gtk.Button scan_button;private ulong error_detail_hid; private Plac.V2.ServerScanner scanner = new Plac.V2.ServerScanner(); private Plac.ServerScanner scanner = new Plac.ServerScanner(); private Gee.HashMap<string, ServerRow>rows = new Gee.HashMap<string, ServerRow>(); public Window(Gtk.Application app) {
-
@@ -114,8 +114,8 @@private class ServerRow : Adw.ActionRow { public signal void open(); private Plac.V2.Server _server; public Plac.V2.Server server { private Plac.Server _server; public Plac.Server server { get { return _server; } construct set { _server = value;
-
@@ -124,7 +124,7 @@ this.subtitle = value.version;} } public ServerRow(Plac.V2.Server server) { public ServerRow(Plac.Server server) { Object(server: server); }
-