Changes
13 changed files (+936/-0)
-
cli/.gitignore (new)
-
@@ -0,0 +1,20 @@# Copyright 2025 Shota FUJI # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # What: Connection state file, containing tokens and IDs. # Why: CLI automatically generates and loads this file, and the content is unique to each # local network and changes by user operations. .roon.json
-
-
-
@@ -0,0 +1,226 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const clap = @import("clap"); const core = @import("core"); const ExitCode = @import("../exit.zig").ExitCode; const find = @import("./server/find.zig"); const list = @import("./server/list.zig"); const parser = .{ .id = clap.parsers.string, .path = clap.parsers.string, }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\-c, --core-id <id> Roon Server's ID. \\-s, --state <path> Path to state file to load from and save to. \\ ); const ExtensionRegistration = struct { core_id: []const u8, token: []const u8, }; const RoonJson = struct { registrations: []ExtensionRegistration = &.{}, }; pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator) ExitCode { var diag = clap.Diagnostic{}; var res = clap.parseEx(clap.Help, ¶ms, parser, iter, .{ .diagnostic = &diag, .allocator = allocator, }) catch |err| { diag.report(std.io.getStdErr().writer(), err) catch {}; return ExitCode.incorrect_usage; }; defer res.deinit(); const stdout = std.io.getStdOut().writer(); const stderr = std.io.getStdErr().writer(); if (res.args.help > 0) { clap.help(stdout, clap.Help, ¶ms, .{}) catch {}; return ExitCode.ok; } const state_path = res.args.state orelse ".roon.json"; const state_file = std.fs.cwd().openFile(state_path, .{ .mode = .read_write }) catch |err| new: { switch (err) { error.FileNotFound => { const new = std.fs.cwd().createFile(state_path, .{ .read = true }) catch |new_err| { stderr.print("Unable to create {s}: {s}\n", .{ state_path, @errorName(new_err) }) catch {}; return ExitCode.not_ok; }; std.json.stringify(RoonJson{}, .{}, new.writer()) catch |write_err| { stderr.print("Unable to write to {s}: {s}\n", .{ state_path, @errorName(write_err) }) catch {}; return ExitCode.not_ok; }; new.seekTo(0) catch |seek_err| { stderr.print("Unable to seek {s} to head: {s}\n", .{ state_path, @errorName(seek_err) }) catch {}; return ExitCode.not_ok; }; break :new new; }, else => { stderr.print("Failed to open {s}: {s}\n", .{ state_path, @errorName(err) }) catch {}; return ExitCode.not_ok; }, } }; defer state_file.close(); const state_contents = state_file.readToEndAlloc(allocator, std.math.maxInt(usize)) catch |err| { stderr.print("Unable to read {s}: {s}\n", .{ state_path, @errorName(err) }) catch {}; return ExitCode.not_ok; }; defer allocator.free(state_contents); state_file.seekTo(0) catch |err| { stderr.print("Unable to seek {s} to head: {s}\n", .{ state_path, @errorName(err) }) catch {}; return ExitCode.not_ok; }; const prev_state = std.json.parseFromSlice(RoonJson, allocator, state_contents, .{}) catch |err| { stderr.print("Unable to parse {s}: {s}\n", .{ state_path, @errorName(err) }) catch {}; return ExitCode.not_ok; }; defer prev_state.deinit(); const core_id = res.args.@"core-id" orelse { stderr.print("--core-id is required\n", .{}) catch {}; clap.help(stderr, clap.Help, ¶ms, .{}) catch {}; return ExitCode.incorrect_usage; }; const scanner = core.server.ServerScanner.make() orelse { // TODO: Print as error log stderr.print("Unable to create a scanner: out of memory\n", .{}) catch {}; // TODO: Create dedicated exit code. return ExitCode.not_ok; }; defer scanner.free(); const core_id_cstr = allocator.dupeZ(u8, core_id) catch { stderr.print("Failed to create connection: OOM\n", .{}) catch {}; return ExitCode.not_ok; }; defer allocator.free(core_id_cstr); var result = scanner.find(core_id_cstr.ptr, core_id_cstr.len) orelse { // TODO: Print as error log stderr.print("Unable to find server: out of memory\n", .{}) catch {}; // TODO: Create dedicated exit code. return ExitCode.not_ok; }; defer result.free(); if (result.code != .ok) { // TODO: Print as error log stderr.print("Failed to find server: {s}\n", .{@tagName(result.code)}) catch {}; return ExitCode.not_ok; } const server = result.server orelse { // TODO: Print as error log stderr.print("No Roon Server id={s} found\n", .{core_id}) catch {}; return ExitCode.not_ok; }; const app_ptr = core.Application.makeFromServer(server); const app = app_ptr orelse { stderr.print("Failed to initialize Application: OOM\n", .{}) catch {}; return ExitCode.not_ok; }; defer app.free(); var token: ?[:0]const u8 = null; for (prev_state.value.registrations) |r| { if (std.mem.eql(u8, r.core_id, core_id)) { token = allocator.dupeZ(u8, r.token) catch { stderr.print("Failed to load token: OOM\n", .{}) catch {}; return ExitCode.not_ok; }; break; } } defer if (token) |tok| allocator.free(tok); const new_token = app.connect(if (token) |tok| tok.ptr else null) orelse { // TODO: Print as error log stderr.print("Unable to connect\n", .{}) catch {}; return ExitCode.not_ok; }; var registrations = std.ArrayList(ExtensionRegistration).init(allocator); defer registrations.deinit(); for (prev_state.value.registrations) |r| { if (std.mem.eql(u8, r.core_id, core_id)) { registrations.append(ExtensionRegistration{ .core_id = r.core_id, .token = std.mem.span(new_token), }) catch { // TODO: Print as error log stderr.print("Out of memory on writing state\n", .{}) catch {}; return ExitCode.not_ok; }; } else { registrations.append(r) catch { // TODO: Print as error log stderr.print("Out of memory on writing state\n", .{}) catch {}; return ExitCode.not_ok; }; } } if (token == null) { registrations.append(ExtensionRegistration{ .core_id = core_id, .token = std.mem.span(new_token), }) catch { // TODO: Print as error log stderr.print("Out of memory on writing state\n", .{}) catch {}; return ExitCode.not_ok; }; } const new_state = RoonJson{ .registrations = registrations.toOwnedSlice() catch { // TODO: Print as error log stderr.print("Out of memory on writing state\n", .{}) catch {}; return ExitCode.not_ok; }, }; std.json.stringify(new_state, .{}, state_file.writer()) catch |err| { // TODO: Print as error log stderr.print("Unable to write to {s}: {s}\n", .{ state_path, @errorName(err) }) catch {}; return ExitCode.not_ok; }; std.debug.print("Registration complete. token: {s}\n", .{new_token}); return ExitCode.ok; }
-
-
-
@@ -17,14 +17,17 @@const std = @import("std"); const clap = @import("clap"); const build_config = @import("build_config"); const core = @import("core"); const ExitCode = @import("./exit.zig").ExitCode; const server = @import("./commands/server.zig"); const register = @import("./commands/register.zig"); const version = "0.0.0"; const Commands = enum { server, register, version, };
-
@@ -39,6 +42,7 @@ \\<command>\\Available commands: \\* version ... Prints version to stdout and exits. \\* server ... Lists or gets information of Roon Server on network \\* register ... Register extension to Roon Server and exists. \\ );
-
@@ -92,5 +96,6 @@ .version => {try std.fmt.format(std.io.getStdOut().writer(), "{s}\n", .{version}); return @intFromEnum(ExitCode.ok); }, .register => return @intFromEnum(register.run(allocator, &iter)), } }
-
-
-
@@ -32,6 +32,18 @@break :sood dep.module("sood"); }; const moo = moo: { const dep = b.dependency("libmoo", .{}); break :moo dep.module("moo"); }; const websocket = websocket: { const dep = b.dependency("websocket", .{}); break :websocket dep.module("websocket"); }; // Zig module { const mod = b.addModule("core", .{
-
@@ -39,6 +51,8 @@ .root_source_file = b.path("src/lib.zig"),}); mod.addImport("sood", sood); mod.addImport("moo", moo); mod.addImport("websocket", websocket); } // XCFramework
-
@@ -82,6 +96,8 @@ compile.bundle_compiler_rt = true;compile.bundle_ubsan_rt = true; compile.root_module.addImport("sood", sood); compile.root_module.addImport("moo", moo); compile.root_module.addImport("websocket", websocket); } const universal_lib = universal: {
-
@@ -140,6 +156,8 @@ }),}); lib.root_module.addImport("sood", sood); lib.root_module.addImport("moo", moo); lib.root_module.addImport("websocket", websocket); lib.linkLibC(); lib.installHeader(b.path("src/lib.h"), "plac_core.h");
-
-
-
@@ -23,6 +23,14 @@ .sood = .{.url = "https://git.pocka.jp/libsood.git/archive/8080245c2696cc6404b0628e5c3eb363cb80014f.tar.gz", .hash = "sood-0.0.0-A_jj-7ITAQAPlaaR2AHFXwPvBWzNBCCPTT--OCHnRQ_i", }, .libmoo = .{ .url = "https://git.pocka.jp/libmoo.git/archive/281d63245052c303c8851c4bfcbb428d78f9b52f.tar.gz", .hash = "libmoo-0.0.0-HVqw0sQVAQBtlGvtbF7pEkBkw5FKNr6zBYbGlHBienyE", }, .websocket = .{ .url = "https://github.com/karlseguin/websocket.zig/archive/c1c53b062eab871b95b70409daadfd6ac3d6df61.tar.gz", .hash = "websocket-0.1.0-ZPISdYBIAwB1yO6AFDHRHLaZSmpdh4Bz4dCmaQUqNNWh", }, }, .paths = .{ "build.zig",
-
-
core/src/application.zig (new)
-
@@ -0,0 +1,127 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const builtin = @import("builtin"); const std = @import("std"); const moo = @import("moo"); const websocket = @import("websocket"); const Connection = @import("./roon/connection.zig").Connection; const Server = @import("./server/Server.zig").Server; const Extension = @import("./roon/extension.zig").Extension; const PingService = @import("./roon/services/ping.zig").PingService; const RegistryService = @import("./roon/services/registry.zig").RegistryService; const TransportService = @import("./roon/services/transport.zig").TransportService; const allocator = std.heap.c_allocator; pub const PlaybackState = enum(c_int) { /// Completely lost, or loading from server. unknown = 0, /// No track is selected. stopped = 1, /// Track is selected but playback is paused. not_playing = 2, /// In transition of not_playing -> playing requesting_play = 3, /// Playing a track. playing = 4, /// In transition of playing -> not_playing requesting_pause = 5, }; pub const Zone = extern struct { id: [*:0]const u8, id_len: usize, playback_state: PlaybackState, }; const AppExtension = Extension(.{ .id = "jp.pocka.plac", .display_name = "Plac", .version = "0.0.0-dev", .publisher = "Shota FUJI", .email = "pockawoooh@gmail.com", .required_services = &.{TransportService}, .optional_services = &.{}, .provided_services = &.{PingService}, }); pub const Application = extern struct { addr: std.net.Address, conn: ?*Connection = null, /// Returns `null` on OOM. pub fn makeFromServer(server_ptr: ?*const Server) callconv(.C) ?*Application { const server = server_ptr orelse return null; const conn = allocator.create(Application) catch return null; conn.* = .{ .addr = std.net.Address.initPosix(&server.sockaddr), }; return conn; } pub fn free(self_ptr: ?*Application) callconv(.C) void { if (self_ptr) |self| { if (self.conn) |conn| { conn.deinit(); allocator.destroy(conn); } allocator.destroy(self); } } // TODO: Return status code AND token pub fn connect(self_ptr: ?*Application, token: ?[*:0]const u8) callconv(.C) ?[*:0]const u8 { var self = self_ptr orelse return null; const conn = allocator.create(Connection) catch return null; conn.* = Connection.init(allocator, self.addr) catch { // TODO: Handle error return null; }; errdefer conn.deinit(); conn.listen(PingService.handleRequest) catch { return null; }; const info = RegistryService.info(allocator, conn) catch |err| { std.debug.print("Info request failed: {s}\n", .{@errorName(err)}); return null; }; defer info.deinit(); const extension = AppExtension{ .token = if (token) |tok| std.mem.span(tok) else null, }; const register = RegistryService.register(AppExtension, allocator, conn, extension) catch |err| { std.debug.print("Register request failed: {s}\n", .{@errorName(err)}); return null; }; defer register.deinit(); self.conn = conn; return allocator.dupeZ(u8, register.value.token) catch return null; } };
-
-
-
@@ -17,6 +17,7 @@//! This is an entrypoint of the static library. pub const server = @import("./server.zig"); pub const Application = @import("./application.zig").Application; comptime { @export(&server.Server.dupe, .{ .name = "plac_server_dupe" });
-
-
-
@@ -0,0 +1,244 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); const websocket = @import("websocket"); const Response = struct { wrote: *std.Thread.ResetEvent, /// `null` on OOM. data: ?[]const u8, }; const ResponsesStore = std.AutoHashMap(i64, *Response); const RequestHandler = *const fn ( response_writer: anytype, meta: moo.Metadata, header_ctx: moo.HeaderParsingContext, message: []const u8, ) anyerror!bool; pub const Connection = struct { allocator: std.mem.Allocator, thread_safe_allocator: *std.heap.ThreadSafeAllocator, ws: websocket.Client, addr: []const u8, rng: std.Random.Xoshiro256, thread: ?std.Thread = null, responses: ResponsesStore, responses_mutex: *std.Thread.Mutex, pub const InitError = error{ InvalidAddress, WebSocketClientCreationError, WebSocketHandshakeError, PRNGSeedGenerationFailure, } || std.mem.Allocator.Error; pub fn init(child_allocator: std.mem.Allocator, address: std.net.Address) InitError!@This() { var tsa = try child_allocator.create(std.heap.ThreadSafeAllocator); tsa.* = std.heap.ThreadSafeAllocator{ .child_allocator = child_allocator }; const allocator = tsa.allocator(); var addr = std.ArrayList(u8).init(allocator); defer addr.deinit(); try addr.writer().print("{}", .{address}); var addr_string = try addr.toOwnedSlice(); errdefer allocator.free(addr_string); // Zig std always prints "<addr>:<port>" for IPv4 and IPv6 const port_start = std.mem.lastIndexOfScalar(u8, addr_string, ':') orelse { unreachable; }; var client = websocket.Client.init(allocator, .{ .port = address.getPort(), .host = addr_string[0..port_start], }) catch return InitError.WebSocketClientCreationError; errdefer client.deinit(); client.handshake("/api", .{ .timeout_ms = 1_000, }) catch return InitError.WebSocketHandshakeError; const responses = ResponsesStore.init(allocator); const responses_mutex = try allocator.create(std.Thread.Mutex); responses_mutex.* = std.Thread.Mutex{}; return .{ .allocator = allocator, .ws = client, .addr = addr_string, .rng = std.Random.DefaultPrng.init(seed: { var seed: u64 = undefined; std.posix.getrandom(std.mem.asBytes(&seed)) catch { return InitError.PRNGSeedGenerationFailure; }; break :seed seed; }), .responses = responses, .responses_mutex = responses_mutex, .thread_safe_allocator = tsa, }; } pub fn listen(self: *Connection, on_request: RequestHandler) !void { self.thread = try std.Thread.spawn(.{}, readLoop, .{ self, on_request }); } pub fn deinit(self: *Connection) void { self.ws.close(.{}) catch { std.debug.print("Failed to close WebSocket connection\n", .{}); }; if (self.thread) |thread| { // Wait for read thread to terminate. thread.join(); self.thread = null; } self.ws.deinit(); self.responses_mutex.lock(); // TODO: Release response bytes. self.responses.deinit(); self.responses_mutex.unlock(); self.allocator.destroy(self.responses_mutex); self.allocator.free(self.addr); self.thread_safe_allocator.child_allocator.destroy(self.thread_safe_allocator); self.thread_safe_allocator = undefined; } pub fn newRequestId(self: *Connection) i64 { return self.rng.random().int(i64); } pub fn request(self: *Connection, request_id: i64, message: []u8) ![]const u8 { var wrote = std.Thread.ResetEvent{}; var response = Response{ .wrote = &wrote, .data = null, }; { self.responses_mutex.lock(); defer self.responses_mutex.unlock(); try self.responses.put(request_id, &response); } defer { self.responses_mutex.lock(); _ = self.responses.remove(request_id); self.responses_mutex.unlock(); } try self.ws.writeBin(message); wrote.wait(); return response.data orelse return std.mem.Allocator.Error.OutOfMemory; } }; // TODO: Better logging fn readLoop(conn: *Connection, on_request: RequestHandler) void { conn.ws.readTimeout(1_000) catch |err| { std.debug.print("Unable to set read timeout: {s}\n", .{@errorName(err)}); return; }; while (true) { const msg = conn.ws.read() catch return orelse continue; defer conn.ws.done(msg); switch (msg.type) { // NOTE: roon-node-api does not check whether message is binaryType. .text, .binary => { const meta, const header_ctx = moo.Metadata.parse(msg.data) catch |err| { std.debug.print("Received non-MOO message: {s}\n", .{@errorName(err)}); continue; }; // We just want to know Request-Id header. const header, _ = moo.NoBodyHeaders.parse(msg.data, header_ctx) catch |err| { std.debug.print("Received non-MOO message: {s}\n", .{@errorName(err)}); continue; }; { conn.responses_mutex.lock(); defer conn.responses_mutex.unlock(); if (conn.responses.get(header.request_id)) |store| { if (store.wrote.isSet()) { std.debug.print( "Received duplicated message: Request-Id={d}\n", .{header.request_id}, ); continue; } defer store.wrote.set(); const bytes = conn.allocator.dupe(u8, msg.data) catch { std.debug.print("Out of memory during cloning WebSocket message", .{}); return; }; store.data = bytes; continue; } } var buffer = std.ArrayList(u8).init(conn.allocator); defer buffer.deinit(); const wrote = on_request(buffer.writer(), meta, header_ctx, msg.data) catch |err| { std.debug.print("Request handling error: {s}\n", .{@errorName(err)}); continue; }; if (!wrote) { std.debug.print("Incoming message not processed: service={s}\n", .{meta.service}); continue; } const bytes = buffer.toOwnedSlice() catch |err| { std.debug.print("Unable to prepare response bytes: {s}\n", .{@errorName(err)}); continue; }; conn.ws.writeBin(bytes) catch |err| { std.debug.print("Failed to write response message: {s}\n", .{@errorName(err)}); continue; }; }, .ping => conn.ws.writePong(msg.data) catch {}, .pong => {}, .close => { conn.ws.close(.{}) catch return; break; }, } } }
-
-
-
@@ -0,0 +1,80 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 pub const StaticExtensionInfo = struct { id: []const u8, display_name: []const u8, version: []const u8, publisher: []const u8, email: []const u8, required_services: []const type, optional_services: []const type, provided_services: []const type, }; /// Extension object to send to Roon Server. pub fn Extension(info: StaticExtensionInfo) type { return struct { token: ?[]const u8 = null, pub fn jsonStringify(self: *const @This(), jws: anytype) !void { try jws.beginObject(); try jws.objectField("extension_id"); try jws.write(info.id); try jws.objectField("display_name"); try jws.write(info.display_name); try jws.objectField("display_version"); try jws.write(info.version); try jws.objectField("publisher"); try jws.write(info.publisher); try jws.objectField("email"); try jws.write(info.email); if (self.token) |token| { try jws.objectField("token"); try jws.write(token); } try jws.objectField("required_services"); try jws.beginArray(); inline for (info.required_services) |Service| { try jws.write(Service.id); } try jws.endArray(); try jws.objectField("optional_services"); try jws.beginArray(); inline for (info.optional_services) |Service| { try jws.write(Service.id); } try jws.endArray(); try jws.objectField("provided_services"); try jws.beginArray(); inline for (info.provided_services) |Service| { try jws.write(Service.service_id); } try jws.endArray(); try jws.endObject(); } }; }
-
-
-
@@ -0,0 +1,45 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); pub fn ServiceServer(comptime Service: type) type { return struct { pub const service_id: []const u8 = Service.id; /// Returns whether the request is handled. pub fn handleRequest( response_writer: anytype, meta: moo.Metadata, parser_ctx: moo.HeaderParsingContext, message: []const u8, ) !bool { inline for (@typeInfo(Service).@"struct".decls) |decl| { if (@typeInfo(@TypeOf(@field(Service, decl.name))) != .@"fn") { continue; } if (std.mem.eql(u8, service_id ++ "/" ++ decl.name, meta.service)) { try @field(Service, decl.name)(response_writer, parser_ctx, message); return true; } } return false; } }; }
-
-
-
@@ -0,0 +1,39 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); const ServiceServer = @import("../service.zig").ServiceServer; pub const PingService = ServiceServer(struct { pub const id = "com.roonlabs.ping:1"; pub fn ping(writer: anytype, parser_ctx: moo.HeaderParsingContext, message: []const u8) !void { const req_header, _ = try moo.NoBodyHeaders.parse(message, parser_ctx); const meta = moo.Metadata{ .service = "Success", .verb = "COMPLETE", }; const body = moo.NoBody{}; const header = body.getHeader(req_header.request_id); try moo.encode(writer, meta, header, body); } });
-
-
-
@@ -0,0 +1,96 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); const Connection = @import("../connection.zig").Connection; const ext = @import("../extension.zig"); pub const RegistryService = struct { const id = "com.roonlabs.registry:1"; pub const InfoResponse = struct { core_id: []const u8, display_name: []const u8, display_version: []const u8, }; pub fn info(allocator: std.mem.Allocator, conn: *Connection) !moo.JsonBody(InfoResponse) { const request_id = conn.newRequestId(); const meta = moo.Metadata{ .service = id ++ "/info", .verb = "REQUEST", }; const body = moo.NoBody{}; const header = body.getHeader(request_id); const request = try allocator.alloc(u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize()); defer allocator.free(request); var fbs = std.io.fixedBufferStream(request); try moo.encode(fbs.writer(), meta, header, body); const message = try conn.request(request_id, request); errdefer conn.allocator.free(message); _, const header_ctx = try moo.Metadata.parse(message); _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); return try moo.JsonBody(InfoResponse).parse(allocator, message, body_ctx, .{ .ignore_unknown_fields = true }); } pub const RegisterResponse = struct { core_id: []const u8, token: []const u8, }; pub const RegisterError = error{ NonSuccessResponse, }; pub fn register(comptime Extension: type, allocator: std.mem.Allocator, conn: *Connection, extension: Extension) !moo.JsonBody(RegisterResponse) { const request_id = conn.newRequestId(); const meta = moo.Metadata{ .service = id ++ "/register", .verb = "REQUEST", }; const body = moo.JsonBody(Extension).init(&extension); const header = body.getHeader(request_id); const request = try allocator.alloc(u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize()); defer allocator.free(request); var fbs = std.io.fixedBufferStream(request); try moo.encode(fbs.writer(), meta, header, body); const message = try conn.request(request_id, request); errdefer conn.allocator.free(message); const meta_res, const header_ctx = try moo.Metadata.parse(message); if (!std.mem.eql(u8, meta_res.service, "Registered")) { std.debug.print("Expected \"Registered\" for /register endpoint, got \"{s}\"\n", .{meta_res.service}); return RegisterError.NonSuccessResponse; } _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); return try moo.JsonBody(RegisterResponse).parse(allocator, message, body_ctx, .{ .ignore_unknown_fields = true }); } };
-
-
-
@@ -0,0 +1,27 @@// Copyright 2025 Shota FUJI // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // // SPDX-License-Identifier: Apache-2.0 const std = @import("std"); const moo = @import("moo"); const Connection = @import("../connection.zig").Connection; const Extension = @import("../extension.zig").Extension; /// Use of this service requires registration. pub const TransportService = struct { pub const id = "com.roonlabs.transport:2"; };
-