Changes
16 changed files (+2000/-300)
-
-
@@ -18,6 +18,7 @@ const std = @import("std");const core = @import("core"); const cb = @import("./cb.zig"); const State = @import("./app/State.zig"); pub const Inputs = struct {
-
@@ -49,8 +50,7 @@pub const App = struct { allocator: std.mem.Allocator, state: *State, app: *core.Application, connect_result: *core.Application.ConnectResult, app: *core.App.CApi, pub const InitError = error{ ServerFindError,
-
@@ -66,39 +66,11 @@ return err;}; errdefer state.deinit(); const scanner = core.server.ServerScanner.make() orelse { std.log.err("Unable to create a scanner: out of memory", .{}); return std.mem.Allocator.Error.OutOfMemory; }; defer scanner.free(); const core_id_cstr = allocator.dupeZ(u8, inputs.core_id) catch |err| { std.log.err("Failed to create connection: OOM", .{}); return err; }; defer allocator.free(core_id_cstr); var result = scanner.find(core_id_cstr.ptr, core_id_cstr.len) orelse { std.log.err("Unable to find server: out of memory", .{}); var app = core.App.CApi.new() orelse { std.log.err("Unable to create an app: out of memory", .{}); return std.mem.Allocator.Error.OutOfMemory; }; defer result.free(); if (result.code != .ok) { std.log.err("Failed to find server: {s}", .{@tagName(result.code)}); return InitError.ServerFindError; } const server = result.server orelse { std.log.err("No Roon Server found for id={s}", .{inputs.core_id}); return InitError.ServerNotFound; }; const app_ptr = core.Application.makeFromServer(server); const app = app_ptr orelse { std.log.err("Failed to initialize Application: OOM", .{}); return std.mem.Allocator.Error.OutOfMemory; }; errdefer app.destroy(); var token: ?[:0]const u8 = null; for (state.registrations.items) |r| {
-
@@ -112,46 +84,60 @@ }} defer if (token) |tok| allocator.free(tok); const connect_result = app.connect(if (token) |tok| tok.ptr else null) orelse { std.log.err("Unable to allocate connection result struct: OOM", .{}); return std.mem.Allocator.Error.OutOfMemory; }; const OnConnectionChange = cb.Callback(struct {}); var on_connection_change = OnConnectionChange.init(); if (connect_result.code != .ok) { std.log.err( "Unable to connect to Roon server: {s}", .{@tagName(connect_result.code)}, ); return InitError.UnableToConnect; const OnServerChange = cb.Callback(struct {}); var on_server_change = OnServerChange.init(); app.onServerChange(OnServerChange.function, on_server_change.userdata()); app.onConnectionChange(OnConnectionChange.function, on_connection_change.userdata()); const server_id = try allocator.dupeZ(u8, inputs.core_id); defer allocator.free(server_id); const token_ptr: ?[*:0]const u8, const token_len: usize = if (token) |t| .{ t.ptr, t.len } else .{ null, 0 }; app.connect(server_id.ptr, server_id.len, token_ptr, token_len); while (true) { on_connection_change.wait(); switch (app.connection) { core.App.CApi.ConnectionState.busy => continue, core.App.CApi.ConnectionState.idle => break, else => |err| { std.log.err("Unable to connect: {s}", .{@tagName(err)}); return InitError.UnableToConnect; }, } } const new_token = if (connect_result.token) |tok| tok[0..connect_result.token_len] else { std.log.err("Succesfully connected but got empty token", .{}); return InitError.UnableToConnect; }; on_server_change.wait(); state.putToken(inputs.core_id, new_token) catch |err| { std.log.err("Failed to save new token: {s}", .{@errorName(err)}); return err; }; if (app.server) |server| { const new_token = server.token[0..server.token_len]; state.putToken(inputs.core_id, new_token) catch |err| { std.log.err("Failed to save new token: {s}", .{@errorName(err)}); return err; }; state.save() catch |err| { std.log.err("Failed to save state to disk: {s}", .{@errorName(err)}); return err; }; state.save() catch |err| { std.log.err("Failed to save state to disk: {s}", .{@errorName(err)}); return err; }; } return .{ .allocator = allocator, .state = state, .app = app, .connect_result = connect_result, }; } pub fn deinit(self: *@This()) void { self.connect_result.free(); self.state.deinit(); self.allocator.destroy(self.state); self.app.free(); self.app.destroy(); } };
-
-
cli/src/cb.zig (new)
-
@@ -0,0 +1,70 @@// 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 exit = @import("./exit.zig"); pub fn Callback(comptime Arg: type) type { if (@sizeOf(Arg) > 0) { return struct { event: std.Thread.ResetEvent, arg: ?Arg, pub fn init() @This() { return .{ .event = std.Thread.ResetEvent{}, .arg = null }; } pub fn function(ptr: *anyopaque, arg: Arg) callconv(.C) void { var u: *@This() = @ptrCast(@alignCast(ptr)); u.arg = arg; u.event.set(); } pub fn userdata(self: *@This()) *anyopaque { return @ptrCast(self); } pub fn wait(self: *@This()) Arg { self.event.wait(); self.event.reset(); return self.arg orelse @panic("Callback did not set arg (" ++ @typeName(Arg) ++ ")"); } }; } return struct { event: std.Thread.ResetEvent, pub fn init() @This() { return .{ .event = std.Thread.ResetEvent{} }; } pub fn function(ptr: *anyopaque) callconv(.C) void { var u: *@This() = @ptrCast(@alignCast(ptr)); u.event.set(); } pub fn userdata(self: *@This()) *anyopaque { return @ptrCast(self); } pub fn wait(self: *@This()) void { self.event.wait(); self.event.reset(); } }; }
-
-
-
@@ -18,6 +18,7 @@ const std = @import("std");const clap = @import("clap"); const core = @import("core"); const cb = @import("../cb.zig"); const App = @import("../app.zig").App; const ExitCode = @import("../exit.zig").ExitCode;
-
@@ -49,34 +50,53 @@ };return ExitCode.ok; } const conn = plac.app.conn orelse { std.log.err("Connection lost.", .{}); return ExitCode.not_ok; }; const zones = core.services.Transport.getZones(allocator, conn) catch |err| { std.log.err("Unable to get zones: {s}", .{@errorName(err)}); return ExitCode.not_ok; }; defer zones.deinit(); const name = res.args.zone orelse { std.log.err("--zone is required.", .{}); return ExitCode.incorrect_usage; }; for (zones.value.zones) |zone| { if (std.mem.eql(u8, name, zone.display_name)) { std.fmt.format( std.io.getStdOut().writer(), "{s}\n", .{@tagName(zone.state)}, ) catch return ExitCode.stdout_write_failed; const server = plac.app.server orelse unreachable; const OnZoneListLoadingChange = cb.Callback(struct {}); var on_zone_list_loading_change = OnZoneListLoadingChange.init(); server.onZoneListLoadingChange(OnZoneListLoadingChange.function, on_zone_list_loading_change.userdata()); server.loadZones(); while (true) { on_zone_list_loading_change.wait(); return ExitCode.ok; switch (server.zones_loading) { .not_loaded => {}, .loading, .refreshing => { std.log.debug("Zone list loading state changed to {s}", .{@tagName(server.zones_loading)}); continue; }, .loaded => { const zones = server.zones[0..server.zones_len]; for (zones) |zone| { if (std.mem.eql(u8, name, zone.name[0..zone.name_len])) { std.fmt.format( std.io.getStdOut().writer(), "{s}\n", .{@tagName(zone.playback_state)}, ) catch return ExitCode.stdout_write_failed; return ExitCode.ok; } } std.log.err("No zone found named {s}", .{name}); return ExitCode.not_ok; }, else => |err| { std.log.err("Failed to load zones: {s}", .{@tagName(err)}); return switch (err) { .err_out_of_memory => ExitCode.out_of_memory, else => ExitCode.not_ok, }; }, } } std.log.err("No zone found named {s}", .{name}); return ExitCode.not_ok; }
-
-
-
@@ -18,13 +18,11 @@ const std = @import("std");const clap = @import("clap"); const ExitCode = @import("../exit.zig").ExitCode; const find = @import("./server/find.zig"); const list = @import("./server/list.zig"); const Commands = enum { list, ls, find, }; const parser = .{
-
@@ -36,7 +34,6 @@ \\-h, --help Prints this message to stdout and exits.\\<command> \\Available commands: \\* list ... List available Roon Server on network (alias: ls) \\* find ... Find a Roon Server matches to given ID \\ );
-
@@ -66,6 +63,5 @@ };return switch (command) { .ls, .list => list.run(allocator, iter), .find => find.run(allocator, iter), }; }
-
-
cli/src/commands/server/find.zig (deleted)
-
@@ -1,123 +0,0 @@// 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 OutputFormat = enum { address, text, json, }; const parser = .{ .format = clap.parsers.enumeration(OutputFormat), .server_id = clap.parsers.string, }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\-f, --format <format> Output format. \\Available values are: \\* address ... Only display an IP address and port number. \\* text ... Plain text format for human consumption. \\* json ... A JSON object. \\<server_id> \\ ); 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 server_id = res.positionals[0] orelse { std.log.err("server_id is required.", .{}); clap.help(stderr, clap.Help, ¶ms, .{}) catch {}; return ExitCode.incorrect_usage; }; const scanner = core.server.ServerScanner.make() orelse { std.log.err("Unable to create a scanner: out of memory", .{}); return ExitCode.out_of_memory; }; defer scanner.free(); const server_id_cstr = allocator.dupeZ(u8, server_id) catch { std.log.err("Unable to find server: out of memory", .{}); return ExitCode.out_of_memory; }; defer allocator.free(server_id_cstr); var result = scanner.find(server_id_cstr.ptr, server_id_cstr.len) orelse { std.log.err("Unable to find server: out of memory", .{}); return ExitCode.out_of_memory; }; defer result.free(); if (result.code != .ok) { std.log.err("Failed to find server: {s}", .{@tagName(result.code)}); return ExitCode.not_ok; } const found = result.server orelse { std.log.err("Server not found", .{}); return ExitCode.not_ok; }; switch (res.args.format orelse .address) { .address => { stdout.print("{}\n", .{found.getAddr()}) catch { return ExitCode.stdout_write_failed; }; }, .text => { stdout.print("ID={s} IP={} VERSION=\"{s}\"\n", .{ found.getId(), found.getAddr(), found.getVersion(), }) catch { return ExitCode.stdout_write_failed; }; }, .json => { stdout.print("{}\n", .{ std.json.fmt(found, .{ .whitespace = .minified }), }) catch { return ExitCode.stdout_write_failed; }; }, } return ExitCode.ok; }
-
-
-
@@ -18,6 +18,7 @@ const std = @import("std");const clap = @import("clap"); const core = @import("core"); const cb = @import("../../cb.zig"); const ExitCode = @import("../../exit.zig").ExitCode; const OutputFormat = enum {
-
@@ -28,14 +29,10 @@ };const parser = .{ .format = clap.parsers.enumeration(OutputFormat), .sec = clap.parsers.int(u32, 10), .uint = clap.parsers.int(u32, 10), }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\-c, --count <uint> How many discovery requests to send. \\-w, --wait <sec> Waits <sec> after sending a discovery request. \\--header Whether emits header row (only on --format=tsv) \\-f, --format <format> Output format. \\Available values are:
-
@@ -63,89 +60,96 @@ };return ExitCode.ok; } const count = res.args.count orelse 3; const wait = res.args.wait orelse 2; var opts: core.server.ScanOptions = undefined; opts.init(); opts.count = count; opts.receive_window_ms = wait * 1_000; const maybe_scanner = core.server.ServerScanner.make(); const scanner = maybe_scanner orelse { std.log.err("Unable to create a scanner: out of memory", .{}); var app = core.App.CApi.new() orelse { std.log.err("Unable to create an app: out of memory", .{}); return ExitCode.out_of_memory; }; defer scanner.free(); defer app.destroy(); var result = scanner.scan(&opts) orelse { std.log.err("Unable to scan: out of memory", .{}); return ExitCode.out_of_memory; }; defer result.free(); const OnChange = cb.Callback(struct {}); var on_change = OnChange.init(); if (result.code != .ok) { std.log.err("Failed to scan: {s}", .{@tagName(result.code)}); return ExitCode.not_ok; } app.server_selector.onChange(OnChange.function, on_change.userdata()); const stdout = std.io.getStdOut().writer(); app.server_selector.load(); switch (res.args.format orelse .text) { .text => { while (result.next()) |server| { defer server.free(); while (true) { on_change.wait(); stdout.print("ID={s} IP={} VERSION=\"{s}\"\n", .{ server.getId(), server.getAddr(), server.getVersion(), }) catch { return ExitCode.stdout_write_failed; }; } }, .tsv => { if (res.args.header > 0) { stdout.writeAll("ID\tName\tIP address\tVersion\n") catch { return ExitCode.stdout_write_failed; }; } while (result.next()) |server| { defer server.free(); std.log.debug("ServerSelector.state changed to {s}", .{ @tagName(app.server_selector.state), }); const name = allocator.dupe(u8, server.getName()) catch { return ExitCode.out_of_memory; }; std.mem.replaceScalar(u8, name, '\t', ' '); switch (app.server_selector.state) { .loading, .refreshing, .not_loaded => continue, .err_out_of_memory => { std.log.err("Out of memory.", .{}); return ExitCode.out_of_memory; }, .loaded => { if (app.server_selector.entries_len == 0) { std.log.info("No servers found.", .{}); return ExitCode.ok; } const version = allocator.dupe(u8, server.getVersion()) catch { return ExitCode.out_of_memory; }; std.mem.replaceScalar(u8, version, '\t', ' '); const stdout = std.io.getStdOut().writer(); stdout.print("{s}\t{s}\t{}\t{s}\n", .{ server.getId(), name, server.getAddr(), version, }) catch { return ExitCode.stdout_write_failed; }; } }, .jsonl => { while (result.next()) |server| { defer server.free(); switch (res.args.format orelse .text) { .text => { for (app.server_selector.entries[0..app.server_selector.entries_len]) |server| { stdout.print("ID={s} IP={} VERSION=\"{s}\"\n", .{ server.getId(), server.getAddr(), server.getVersion(), }) catch { return ExitCode.stdout_write_failed; }; } }, .tsv => { if (res.args.header > 0) { stdout.writeAll("ID\tName\tIP address\tVersion\n") catch { return ExitCode.stdout_write_failed; }; } for (app.server_selector.entries[0..app.server_selector.entries_len]) |server| { const name = allocator.dupe(u8, server.getName()) catch { return ExitCode.out_of_memory; }; std.mem.replaceScalar(u8, name, '\t', ' '); const version = allocator.dupe(u8, server.getVersion()) catch { return ExitCode.out_of_memory; }; std.mem.replaceScalar(u8, version, '\t', ' '); stdout.print("{s}\t{s}\t{}\t{s}\n", .{ server.getId(), name, server.getAddr(), version, }) catch { return ExitCode.stdout_write_failed; }; } }, .jsonl => { for (app.server_selector.entries[0..app.server_selector.entries_len]) |server| { stdout.print("{}\n", .{ std.json.fmt(server, .{ .whitespace = .minified }), }) catch { return ExitCode.stdout_write_failed; }; } }, } stdout.print("{}\n", .{ std.json.fmt(server, .{ .whitespace = .minified }), }) catch { return ExitCode.stdout_write_failed; }; } }, return ExitCode.ok; }, else => |err| { std.log.err("Failed to scan: {s}", .{@tagName(err)}); return ExitCode.not_ok; }, } } return ExitCode.ok; }
-
-
core/src/App.zig (new)
-
@@ -0,0 +1,436 @@// 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 sood = @import("sood"); const Connection = @import("./roon/connection.zig").Connection; const discovery = @import("./roon/discovery.zig"); 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 callback = @import("./App/callback.zig"); pub const Server = @import("./App/Server.zig"); pub const ServerSelector = @import("./App/ServerSelector.zig"); const App = @This(); 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}, }); const OnServerChange = callback.Callback(struct {}); const OnConnectionChange = callback.Callback(struct {}); const ServerConnInfo = struct { allocator: std.mem.Allocator, addr: std.net.Address, id: []const u8, name: []const u8, version: []const u8, pub const FromSoodResponseError = std.mem.Allocator.Error; pub fn fromSoodResponse( allocator: std.mem.Allocator, addr: std.net.Address, resp: *const sood.discovery.Response, ) FromSoodResponseError!@This() { var ws_addr = addr; ws_addr.setPort(resp.http_port); const id = try allocator.dupe(u8, resp.unique_id); errdefer allocator.free(id); const name = try allocator.dupe(u8, resp.name); errdefer allocator.free(name); const version = try allocator.dupe(u8, resp.display_version); errdefer allocator.free(version); return .{ .allocator = allocator, .addr = ws_addr, .id = id, .name = name, .version = version, }; } pub fn deinit(self: @This()) void { self.allocator.free(self.version); self.allocator.free(self.name); self.allocator.free(self.id); } pub fn getKey(self: @This()) []const u8 { return self.id; } }; allocator: std.mem.Allocator, on_server_change: OnServerChange.Store, on_connection_change: OnConnectionChange.Store, capi_lock: std.Thread.Mutex, pub fn init(allocator: std.mem.Allocator) App { return .{ .allocator = allocator, .on_server_change = OnServerChange.Store.init(allocator), .on_connection_change = OnConnectionChange.Store.init(allocator), .capi_lock = std.Thread.Mutex{}, }; } pub fn deinit(self: *App) void { self.on_connection_change.deinit(); self.on_server_change.deinit(); } pub const CApi = extern struct { internal: *App, server_selector: *ServerSelector.CApi, connection: ConnectionState = .idle, server: ?*Server.CApi = null, pub const ConnectionState = enum(c_int) { idle = 0, busy = 1, err_unexpected = 2, err_network_unavailable = 3, err_socket_permission = 4, err_out_of_memory = 5, err_socket = 6, err_registry_down = 7, err_failed_to_register = 8, err_thread_spawn = 9, err_websocket = 10, err_not_found = 11, }; pub fn init(allocator: std.mem.Allocator) std.mem.Allocator.Error!*CApi { const internal = try allocator.create(App); errdefer allocator.destroy(internal); internal.* = App.init(allocator); errdefer internal.deinit(); const server_selector = try ServerSelector.CApi.init(allocator); errdefer server_selector.deinit(allocator); const capi = try allocator.create(CApi); capi.* = .{ .internal = internal, .server_selector = server_selector, }; return capi; } pub fn new() callconv(.C) ?*CApi { return CApi.init(std.heap.c_allocator) catch return null; } pub fn deinit(self: *CApi, allocator: std.mem.Allocator) void { if (self.server) |server| { server.internal.conn.deinit(); self.internal.allocator.destroy(server.internal.conn); server.deinit(); self.internal.allocator.destroy(server); } self.server_selector.deinit(allocator); self.internal.deinit(); allocator.destroy(self.internal); allocator.destroy(self); } pub fn destroy(self_ptr: ?*CApi) callconv(.C) void { const self = self_ptr orelse return; self.deinit(std.heap.c_allocator); } pub fn connect( self_ptr: ?*CApi, server_id: [*:0]const u8, server_id_len: usize, saved_token_ptr: ?[*:0]const u8, saved_token_len: usize, ) callconv(.C) void { const self = self_ptr orelse return; if (self.connection == .busy) { return; } const id = server_id[0..server_id_len]; const saved_token = if (saved_token_ptr) |tok| tok[0..saved_token_len] else null; if (self.server) |server| { if (std.mem.eql(u8, server.id[0..server.id_len], id)) { return; } } { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = .busy; self.internal.on_connection_change.runAll(.{}); } const thread = std.Thread.spawn(.{}, connectWorker, .{ self, id, saved_token }) catch { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = .err_thread_spawn; self.internal.on_connection_change.runAll(.{}); return; }; thread.detach(); } pub fn onServerChange(capi_ptr: ?*CApi, cb: OnServerChange.Fn, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; // TODO: Notify error to caller capi.internal.on_server_change.add(OnServerChange.init(cb, userdata)) catch {}; } pub fn onServerChangeDisarm(capi_ptr: ?*CApi, cb: OnServerChange.Fn) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_server_change.remove(cb); } pub fn onConnectionChange(capi_ptr: ?*CApi, cb: OnConnectionChange.Fn, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; // TODO: Notify error to caller capi.internal.on_connection_change.add(OnConnectionChange.init(cb, userdata)) catch {}; } pub fn onConnectionChangeDisarm(capi_ptr: ?*CApi, cb: OnConnectionChange.Fn) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_connection_change.remove(cb); } }; fn connectWorker(self: *CApi, server_id: []const u8, saved_token: ?[]const u8) void { if (self.server) |server| { server.internal.conn.deinit(); self.internal.allocator.destroy(server.internal.conn); server.deinit(); self.internal.allocator.destroy(server); self.server = null; } for (self.server_selector.entries[0..self.server_selector.entries_len]) |entry| { if (std.mem.eql(u8, entry.id[0..entry.id_len], server_id)) { const conn, const token = register( self.internal.allocator, entry.getAddr(), server_id, saved_token, ) catch |err| { std.log.warn("Unable to connect to pre-scanned sever, skipping: {s}", .{ @errorName(err), }); continue; }; defer self.internal.allocator.free(token); const server = self.internal.allocator.create(Server.CApi) catch { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = .err_out_of_memory; self.internal.on_connection_change.runAll(.{}); return; }; server.* = Server.CApi.init( self.internal.allocator, entry.getAddr(), conn, entry.getId(), entry.getName(), entry.getVersion(), token, ) catch { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = .err_out_of_memory; self.internal.on_connection_change.runAll(.{}); return; }; self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.server = server; self.connection = .idle; self.internal.on_server_change.runAll(.{}); self.internal.on_connection_change.runAll(.{}); return; } } const info = discovery.resolve(ServerConnInfo, self.internal.allocator, server_id, .{}) catch |err| { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = switch (err) { error.OutOfMemory => App.CApi.ConnectionState.err_out_of_memory, error.SocketPermissionDenied => App.CApi.ConnectionState.err_socket_permission, error.SocketCreationError, error.UDPRecvError, error.UDPSendError, => App.CApi.ConnectionState.err_socket, error.NetworkUnavailable => App.CApi.ConnectionState.err_network_unavailable, else => App.CApi.ConnectionState.err_unexpected, }; self.internal.on_connection_change.runAll(.{}); return; } orelse { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = .err_not_found; self.internal.on_connection_change.runAll(.{}); return; }; defer info.deinit(); const conn, const token = register( self.internal.allocator, info.addr, server_id, saved_token, ) catch |err| { std.log.err("Unable to connect sever: {s}", .{@errorName(err)}); self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = switch (err) { error.OutOfMemory => App.CApi.ConnectionState.err_out_of_memory, RegisterError.RegistryInfoError => App.CApi.ConnectionState.err_registry_down, RegisterError.RegistryRegisterError => App.CApi.ConnectionState.err_failed_to_register, RegisterError.ListeningThreadSpawnError => App.CApi.ConnectionState.err_thread_spawn, RegisterError.ServerIdMismatch => App.CApi.ConnectionState.err_not_found, Connection.InitError.WebSocketClientCreationError, Connection.InitError.WebSocketHandshakeError, => App.CApi.ConnectionState.err_websocket, else => App.CApi.ConnectionState.err_unexpected, }; self.internal.on_connection_change.runAll(.{}); return; }; defer self.internal.allocator.free(token); const server = self.internal.allocator.create(Server.CApi) catch { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = .err_out_of_memory; self.internal.on_connection_change.runAll(.{}); return; }; server.* = Server.CApi.init( self.internal.allocator, info.addr, conn, info.id, info.name, info.version, token, ) catch { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = .err_out_of_memory; self.internal.on_connection_change.runAll(.{}); return; }; self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.server = server; self.connection = .idle; self.internal.on_server_change.runAll(.{}); self.internal.on_connection_change.runAll(.{}); return; } const RegisterError = error{ RegistryInfoError, RegistryRegisterError, ListeningThreadSpawnError, ServerIdMismatch, } || Connection.InitError; fn register( allocator: std.mem.Allocator, address: std.net.Address, server_id: []const u8, saved_token: ?[]const u8, ) !struct { *Connection, []const u8 } { const conn = try allocator.create(Connection); errdefer allocator.destroy(conn); conn.* = try Connection.init(allocator, address); errdefer conn.deinit(); conn.listen(PingService.handleRequest) catch { return RegisterError.ListeningThreadSpawnError; }; std.log.debug("Querying registry status...", .{}); const info = RegistryService.info(allocator, conn) catch |err| { std.log.err("Failed to get extension registry status: {s}", .{@errorName(err)}); return RegisterError.RegistryInfoError; }; defer info.deinit(); if (!std.mem.eql(u8, info.value.core_id, server_id)) { return RegisterError.ServerIdMismatch; } const extension = AppExtension{ .token = saved_token, }; std.log.debug("Registering extension {s} token...", .{if (saved_token) |_| "with" else "without"}); const r = RegistryService.register(AppExtension, allocator, conn, extension) catch |err| { std.log.err("Failed to register extension: {s}", .{@errorName(err)}); return RegisterError.RegistryRegisterError; }; defer r.deinit(); std.log.debug("Extension registered, connection is ready", .{}); const token = try allocator.dupe(u8, r.value.token); return .{ conn, token }; }
-
-
core/src/App/Server.zig (new)
-
@@ -0,0 +1,215 @@// 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 callback = @import("./callback.zig"); const connection = @import("../roon/connection.zig"); const Transport = @import("../roon/services/transport.zig").TransportService; pub const Zone = @import("./Server/Zone.zig"); const Self = @This(); const OnZoneAdd = callback.Callback(*const Zone.CApi); const OnZoneListLoadingChange = callback.Callback(struct {}); allocator: std.mem.Allocator, address: std.net.Address, on_zone_add: OnZoneAdd.Store, on_zone_list_loading_change: OnZoneListLoadingChange.Store, // Unowned. Caller manages the resource. conn: *connection.Connection, pub fn init( allocator: std.mem.Allocator, address: std.net.Address, conn: *connection.Connection, ) std.mem.Allocator.Error!Self { return .{ .allocator = allocator, .address = address, .conn = conn, .on_zone_add = OnZoneAdd.Store.init(allocator), .on_zone_list_loading_change = OnZoneListLoadingChange.Store.init(allocator), }; } pub fn deinit(self: *Self) void { self.on_zone_add.deinit(); self.on_zone_list_loading_change.deinit(); } pub const CApi = extern struct { internal: *Self, id: [*:0]const u8, id_len: usize, name: [*:0]const u8, name_len: usize, version: [*:0]const u8, version_len: usize, zones: [*]*Zone.CApi, zones_len: usize, zones_loading: ZoneListLoading = .not_loaded, token: [*:0]const u8, token_len: usize, pub const ZoneListLoading = enum(c_int) { not_loaded = 0, loading = 1, loaded = 2, refreshing = 3, err_unexpected = 4, err_thread_spawn = 5, err_out_of_memory = 6, err_non_success = 7, }; pub fn init( allocator: std.mem.Allocator, address: std.net.Address, conn: *connection.Connection, id: []const u8, name: []const u8, version: []const u8, token: []const u8, ) std.mem.Allocator.Error!CApi { const internal = try allocator.create(Self); errdefer allocator.destroy(internal); internal.* = try Self.init(allocator, address, conn); errdefer internal.deinit(); const id_z = try allocator.dupeZ(u8, id); errdefer allocator.free(id_z); const name_z = try allocator.dupeZ(u8, name); errdefer allocator.free(name_z); const version_z = try allocator.dupeZ(u8, version); errdefer allocator.free(version_z); const token_z = try allocator.dupeZ(u8, token); errdefer allocator.free(token_z); return CApi{ .internal = internal, .id = id_z.ptr, .id_len = id_z.len, .name = name_z.ptr, .name_len = name_z.len, .version = version_z.ptr, .version_len = version_z.len, .zones = undefined, .zones_len = 0, .token = token_z, .token_len = token_z.len, }; } pub fn deinit(self: *CApi) void { const zones = self.zones[0..self.zones_len]; for (zones) |zone| { zone.deinit(self.internal.allocator); } self.internal.allocator.free(zones); self.internal.allocator.free(self.token[0..self.token_len]); self.internal.allocator.free(self.version[0..self.version_len]); self.internal.allocator.free(self.name[0..self.name_len]); self.internal.allocator.free(self.id[0..self.id_len]); self.internal.deinit(); self.internal.allocator.destroy(self.internal); } pub fn loadZones(capi_ptr: ?*CApi) callconv(.C) void { const capi = capi_ptr orelse return; if (capi.zones_loading == .loading or capi.zones_loading == .refreshing) { std.log.debug("Zone list is already loading, skipping `loadZones`", .{}); return; } std.log.debug("Loading list of zones...", .{}); capi.zones_loading = if (capi.zones_loading == .not_loaded) .loading else .refreshing; const thread = std.Thread.spawn(.{}, loadZonesWorker, .{capi}) catch |err| { std.log.err("Failed to spawn thread for loading zones: {s}", .{@errorName(err)}); capi.zones_loading = .err_thread_spawn; capi.internal.on_zone_list_loading_change.runAll(.{}); return; }; thread.detach(); } fn loadZonesWorker(self: *CApi) void { self.loadZonesWorkerInner() catch |err| { std.log.err("Failed to fetch list of zones: {s}", .{@errorName(err)}); self.zones_loading = switch (err) { error.OutOfMemory => ZoneListLoading.err_out_of_memory, error.NonSuccessResponse => ZoneListLoading.err_non_success, else => ZoneListLoading.err_unexpected, }; self.internal.on_zone_list_loading_change.runAll(.{}); }; } fn loadZonesWorkerInner(self: *CApi) !void { const res = try Transport.getZones(self.internal.allocator, self.internal.conn); defer res.deinit(); const zones = try self.internal.allocator.alloc(*Zone.CApi, res.value.zones.len); errdefer self.internal.allocator.free(zones); for (res.value.zones, 0..) |zone, i| { zones[i] = try Zone.CApi.init(self.internal.allocator, zone); } const prev = self.zones[0..self.zones_len]; for (prev) |z| { z.deinit(self.internal.allocator); } self.internal.allocator.free(prev); self.zones = zones.ptr; self.zones_len = zones.len; self.zones_loading = .loaded; self.internal.on_zone_list_loading_change.runAll(.{}); } pub fn onZoneAdd(capi_ptr: ?*CApi, cb: OnZoneAdd.Fn, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; // TODO: Notify error to caller capi.internal.on_zone_add.add(OnZoneAdd.init(cb, userdata)) catch {}; } pub fn onZoneAddDisarm(capi_ptr: ?*CApi, cb: OnZoneAdd.Fn) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_zone_add.remove(cb); } pub fn onZoneListLoadingChange(capi_ptr: ?*CApi, cb: OnZoneListLoadingChange.Fn, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; // TODO: Notify error to caller capi.internal.on_zone_list_loading_change.add(OnZoneListLoadingChange.init(cb, userdata)) catch {}; } pub fn onZoneListLoadingChangeDisarm(capi_ptr: ?*CApi, cb: OnZoneListLoadingChange.Fn) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_zone_list_loading_change.remove(cb); } };
-
-
-
@@ -0,0 +1,117 @@// 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 callback = @import("../callback.zig"); const Transport = @import("../../roon/services/transport.zig").TransportService; const Self = @This(); const OnChange = callback.Callback(struct {}); const OnDelete = callback.Callback(struct {}); allocator: std.mem.Allocator, on_change_callbacks: OnChange.Store, on_delete_callbacks: OnDelete.Store, pub fn init(allocator: std.mem.Allocator) Self { return .{ .allocator = allocator, .on_change_callbacks = OnChange.Store.init(allocator), .on_delete_callbacks = OnDelete.Store.init(allocator), }; } pub fn deinit(self: *Self) void { self.on_change_callbacks.deinit(); self.on_delete_callbacks.deinit(); } pub const CApi = extern struct { internal: *Self, id: [*:0]const u8, id_len: usize, name: [*:0]const u8, name_len: usize, playback_state: PlaybackState, pub const PlaybackState = enum(c_int) { stopped = 0, paused = 1, playing = 2, }; pub fn init(allocator: std.mem.Allocator, zone: Transport.Zone) std.mem.Allocator.Error!*CApi { const internal = try allocator.create(Self); errdefer allocator.destroy(internal); internal.* = Self.init(allocator); errdefer internal.deinit(); const id = try allocator.dupeZ(u8, zone.zone_id); errdefer allocator.free(id); const name = try allocator.dupeZ(u8, zone.display_name); errdefer allocator.free(name); const capi = try allocator.create(CApi); errdefer allocator.destroy(capi); capi.* = .{ .internal = internal, .id = id.ptr, .id_len = id.len, .name = name.ptr, .name_len = name.len, .playback_state = switch (zone.state) { .loading, .stopped => .stopped, .paused => .paused, .playing => .playing, }, }; return capi; } pub fn deinit(self: *CApi, allocator: std.mem.Allocator) void { allocator.free(self.id[0..self.id_len]); allocator.free(self.name[0..self.name_len]); self.internal.deinit(); allocator.destroy(self.internal); allocator.destroy(self); } pub fn onChange(capi_ptr: ?*CApi, cb: OnChange.Fn, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; // TODO: Notify error to caller capi.internal.on_change_callbacks.add(OnChange.init(cb, userdata)) catch {}; } pub fn onChangeDisarm(capi_ptr: ?*CApi, cb: OnChange.Fn) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_change_callbacks.remove(cb); } pub fn onDelete(capi_ptr: ?*CApi, cb: OnDelete.Fn, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; // TODO: Notify error to caller capi.internal.on_delete_callbacks.add(OnDelete.init(cb, userdata)) catch {}; } pub fn onDeleteDisarm(capi_ptr: ?*CApi, cb: OnDelete.Fn) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_delete_callbacks.remove(cb); } };
-
-
-
@@ -0,0 +1,193 @@// 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 sood = @import("sood"); const Self = @This(); const discovery = @import("../roon/discovery.zig"); const callback = @import("./callback.zig"); pub const Entry = @import("./ServerSelector/Entry.zig"); const OnChange = callback.Callback(struct {}); allocator: std.mem.Allocator, on_change_callbacks: OnChange.Store, capi_lock: std.Thread.Mutex, has_loaded_once: bool = false, pub fn init(allocator: std.mem.Allocator) Self { return .{ .allocator = allocator, .on_change_callbacks = OnChange.Store.init(allocator), .capi_lock = std.Thread.Mutex{}, }; } pub fn deinit(self: *Self) void { self.on_change_callbacks.deinit(); } pub const CApi = extern struct { internal: *Self, state: State = .not_loaded, entries: [*]Entry.CApi, entries_len: usize, pub const State = enum(c_int) { not_loaded = 0, loading = 1, loaded = 2, refreshing = 3, err_unexpected = 4, err_network_unavailable = 5, err_socket_permission = 6, err_out_of_memory = 7, err_socket = 8, err_thread_spawn = 9, }; pub fn init(allocator: std.mem.Allocator) std.mem.Allocator.Error!*CApi { const internal = try allocator.create(Self); errdefer allocator.destroy(internal); internal.* = Self.init(allocator); errdefer internal.deinit(); const capi = try allocator.create(CApi); errdefer allocator.destroy(capi); capi.* = .{ .internal = internal, .entries = undefined, .entries_len = 0, }; return capi; } pub fn deinit(self: *CApi, allocator: std.mem.Allocator) void { self.deinitEntries(); self.internal.deinit(); allocator.destroy(self.internal); allocator.destroy(self); } inline fn deinitEntries(self: *CApi) void { const entries = self.entries[0..self.entries_len]; for (entries) |entry| { entry.deinit(); } self.internal.allocator.free(entries); self.entries_len = 0; } pub fn reset(capi_ptr: ?*CApi) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.capi_lock.lock(); defer capi.internal.capi_lock.unlock(); capi.state = .not_loaded; capi.deinitEntries(); capi.internal.has_loaded_once = false; } pub fn load(capi_ptr: ?*CApi) callconv(.C) void { const capi = capi_ptr orelse return; const thread = std.Thread.spawn(.{}, loadInternal, .{capi}) catch { capi.internal.capi_lock.lock(); defer capi.internal.capi_lock.unlock(); capi.state = .err_thread_spawn; return; }; thread.detach(); } fn loadInternal(self: *CApi) void { { { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.state = if (self.internal.has_loaded_once) .refreshing else .loading; } self.internal.on_change_callbacks.runAll(.{}); } const entries = scan(self.internal.allocator) catch |err| { { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.state = switch (err) { error.NetworkUnavailable => State.err_network_unavailable, error.SocketPermissionDenied => State.err_socket_permission, error.UDPRecvError, error.UDPSendError, error.SocketCreationError, => State.err_socket, error.OutOfMemory => State.err_out_of_memory, else => State.err_unexpected, }; } self.internal.on_change_callbacks.runAll(.{}); return; }; { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.deinitEntries(); self.entries = entries.ptr; self.entries_len = entries.len; self.state = .loaded; self.internal.has_loaded_once = true; } self.internal.on_change_callbacks.runAll(.{}); } /// Caller owns returned slice. fn scan(allocator: std.mem.Allocator) discovery.Error(Entry.CApi)![]Entry.CApi { var servers = try discovery.scan(Entry.CApi, allocator, .{}); defer servers.deinit(); const slice = try allocator.alloc(Entry.CApi, servers.count()); var i: usize = 0; var iter = servers.valueIterator(); while (iter.next()) |server| { std.log.debug("Found server ({s})", .{server.*.getName()}); slice[i] = server.*; i += 1; } return slice; } pub fn onChange(capi_ptr: ?*CApi, cb: OnChange.Fn, user_data: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; // TODO: Notify error to caller capi.internal.on_change_callbacks.add(OnChange.init(cb, user_data)) catch {}; } pub fn onChangeDisarm(capi_ptr: ?*CApi, cb: OnChange.Fn) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_change_callbacks.remove(cb); } };
-
-
-
@@ -0,0 +1,125 @@// 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 sood = @import("sood"); const Self = @This(); allocator: std.mem.Allocator, pub const CApi = extern struct { internal: *Self, /// Network address of the server. sockaddr: std.posix.sockaddr, addrlen: std.posix.socklen_t, /// Unique ID of the server. It won't change even on server reboot. id: [*:0]const u8, id_len: usize, /// Display name of the server. Users who can configure the server can change the name. name: [*:0]const u8, name_len: usize, /// Free-format version string. version: [*:0]const u8, version_len: usize, pub fn deinit(self: CApi) void { self.internal.allocator.free(self.id[0..self.id_len]); self.internal.allocator.free(self.name[0..self.name_len]); self.internal.allocator.free(self.version[0..self.version_len]); self.internal.allocator.destroy(self.internal); } pub const FromSoodResponseError = std.mem.Allocator.Error; pub fn fromSoodResponse( allocator: std.mem.Allocator, addr: std.net.Address, response: *const sood.discovery.Response, ) FromSoodResponseError!CApi { var ip_addr = addr; ip_addr.setPort(response.http_port); const unique_id = try allocator.dupeZ(u8, response.unique_id); errdefer allocator.free(unique_id); const name = try allocator.dupeZ(u8, response.name); errdefer allocator.free(name); const version = try allocator.dupeZ(u8, response.display_version); errdefer allocator.free(version); const internal = try allocator.create(Self); internal.* = .{ .allocator = allocator, }; return .{ .internal = internal, .sockaddr = ip_addr.any, .addrlen = ip_addr.getOsSockLen(), .id = unique_id.ptr, .id_len = unique_id.len, .name = name.ptr, .name_len = name.len, .version = version.ptr, .version_len = version.len, }; } pub fn getAddr(self: *const CApi) std.net.Address { return std.net.Address.initPosix(&self.sockaddr); } pub fn getId(self: *const CApi) []const u8 { return self.id[0..self.id_len]; } pub fn getName(self: *const CApi) []const u8 { return self.name[0..self.name_len]; } pub fn getVersion(self: *const CApi) []const u8 { return self.version[0..self.version_len]; } pub fn getKey(self: CApi) []const u8 { return self.getVersion(); } pub fn jsonStringify(self: *const CApi, jws: anytype) !void { try jws.beginObject(); try jws.objectField("unique_id"); try jws.write(self.getId()); try jws.objectField("name"); try jws.write(self.getName()); try jws.objectField("version"); try jws.write(self.getVersion()); try jws.objectField("address"); try jws.print("\"{}\"", .{self.getAddr()}); try jws.endObject(); } };
-
-
-
@@ -0,0 +1,71 @@// 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"); pub const UserData = *anyopaque; pub fn Callback(comptime T: type) type { return struct { const Self = @This(); callback: Fn, userdata: UserData, pub fn init(f: Fn, userdata: UserData) Self { return .{ .callback = f, .userdata = userdata }; } pub const Fn = if (@sizeOf(T) == 0) *const fn (UserData) callconv(.C) void else *const fn (T, UserData) callconv(.C) void; pub const Store = struct { cbs: std.ArrayList(Self), pub fn init(allocator: std.mem.Allocator) Store { return .{ .cbs = std.ArrayList(Self).init(allocator) }; } pub fn deinit(self: *Store) void { self.cbs.deinit(); } pub fn add(self: *Store, cb: Self) std.mem.Allocator.Error!void { try self.cbs.append(cb); } pub fn remove(self: *Store, f: Fn) void { for (self.cbs.items.len..0) |i| { if (self.cbs.items[i].callback == f) { _ = self.cbs.orderedRemove(i); } } } pub fn runAll(self: *const Store, arg: T) void { for (self.cbs.items) |cb| { if (@sizeOf(T) == 0) { cb.callback(cb.userdata); } else { cb.callback(arg, cb.userdata); } } } }; }; }
-
-
-
@@ -17,7 +17,7 @@ * SPDX-License-Identifier: Apache-2.0* * === * * C89 header file for Plac core's C API. * C99 header file for Plac core's C API. * This file is not checked against a generated library file: carefully write and review * definitions and implementations. */
-
@@ -28,6 +28,7 @@#include <sys/socket.h> /* Server */ // TODO: Remove me typedef struct { struct sockaddr sockaddr; socklen_t const addrlen;
-
@@ -43,6 +44,7 @@ plac_server *plac_server_dupe(plac_server*);void plac_server_free(plac_server*); /* ScanOptions */ // TODO: Remove me typedef struct { unsigned int count; unsigned int receive_window_ms;
-
@@ -51,6 +53,7 @@void plac_server_scan_options_init(plac_server_scan_options*); /* ScanResultCode */ // TODO: Remove me typedef enum { PLAC_SCAN_OK = 0, PLAC_SCAN_UNKNOWN_ERROR = 1,
-
@@ -66,6 +69,7 @@ PLAC_SCAN_NETWORK_UNAVAILABLE = 10,} plac_scan_result_code; /* ScanResult */ // TODO: Remove me typedef struct { plac_scan_result_code const code;
-
@@ -78,6 +82,7 @@ void plac_scan_result_reset(plac_scan_result*);void plac_scan_result_free(plac_scan_result*); /* FindResult */ // TODO: Remove me typedef struct { plac_scan_result_code const code;
-
@@ -87,6 +92,7 @@void plac_find_result_free(plac_find_result*); /* ServerScanner */ // TODO: Remove me typedef struct { const int *__sockfd;
-
@@ -98,6 +104,7 @@ plac_scan_result *plac_server_scanner_scan(plac_server_scanner*, plac_server_scan_options*);plac_find_result *plac_server_scanner_find(plac_server_scanner*, char * const, unsigned int); /* Application.ConnectResultCode */ // TODO: Remove me typedef enum { PLAC_APPLICATION_CONNECT_OK = 0,
-
@@ -111,6 +118,7 @@ PLAC_APPLICATION_CONNECT_LISTEN_THREAD_SPAWN_ERROR = 7,} plac_application_connect_result_code; /* Application.ConnectionResult */ // TODO: Remove me typedef struct { plac_application_connect_result_code const code;
-
@@ -120,7 +128,259 @@ } plac_application_connect_result;void plac_application_connect_result_free(plac_application_connect_result*); /* App.Server.Zone.PlaybackState */ typedef enum { PLAC_APP_PLAYBACK_STOPPED = 0, PLAC_APP_PLAYBACK_PAUSED = 1, PLAC_APP_PLAYBACK_PLAYING = 2, } plac_app_server_zone_playback_state; /* App.Server.Zone */ typedef struct { void* __private; char const * const id; unsigned int const id_len; char const * const name; unsigned int const name_len; plac_app_server_zone_playback_state const playback_state; } plac_app_server_zone; void plac_app_server_zone_on_change(plac_app_server_zone*, void (*)(void*), void*); void plac_app_server_zone_on_change_disarm(plac_app_server_zone*, void (*)(void*)); void plac_app_server_zone_on_remove(plac_app_server_zone*, void (*)(void*), void*); void plac_app_server_zone_on_remove_disarm(plac_app_server_zone*, void (*)(void*)); /* App.Server.ZoneListLoading */ typedef enum { PLAC_APP_SERVER_ZONE_LIST_LOADING_NOT_LOADED = 0, PLAC_APP_SERVER_ZONE_LIST_LOADING_LOADING = 1, PLAC_APP_SERVER_ZONE_LIST_LOADING_LOADED = 2, PLAC_APP_SERVER_ZONE_LIST_LOADING_REFRESHING = 3, PLAC_APP_SERVER_ZONE_LIST_LOADING_ERR_UNEXPECTED = 4, PLAC_APP_SERVER_ZONE_LIST_LOADING_ERR_THREAD_SPAWN = 5, PLAC_APP_SERVER_ZONE_LIST_LOADING_ERR_OUT_OF_MEMORY = 6, PLAC_APP_SERVER_ZONE_LIST_LOADING_ERR_NON_SUCCESS = 7, } plac_app_server_zone_list_loading; /* App.Server */ typedef struct { void* __private; char const * const id; unsigned int const id_len; char const * const name; unsigned int const name_len; char const * const version; unsigned int const version_len; /** * An array of zones. */ plac_app_server_zone *zones; unsigned int const zones_len; plac_app_server_zone_list_loading const zones_loading; char const * const token; unsigned int const token_len; } plac_app_server; void plac_app_server_load_zones(plac_app_server*); void plac_app_server_on_zone_add(plac_app_server*, void (*)(plac_app_server_zone*, void*), void*); void plac_app_server_on_zone_add_disarm(plac_app_server*, void (*)(plac_app_server_zone*, void*)); void plac_app_server_on_zone_list_loading_change(plac_app_server*, void (*)(void*), void*); void plac_app_server_on_zone_list_loading_change_disarm(plac_app_server*, void (*)(void*)); /* App.ServerSelector.Entry */ typedef struct { void* __private; struct sockaddr sockaddr; socklen_t const addrlen; char const * const id; unsigned int const id_len; char const * const name; unsigned int const name_len; const char * const version; unsigned int const version_len; } plac_app_server_selector_entry; /* App.ServerSelector.State */ typedef enum { PLAC_APP_SERVER_SELECTOR_NOT_LOADED = 0, PLAC_APP_SERVER_SELECTOR_LOADING = 1, PLAC_APP_SERVER_SELECTOR_LOADED = 2, PLAC_APP_SERVER_SELECTOR_REFRESHING = 3, PLAC_APP_SERVER_SELECTOR_ERR_UNEXPECTED = 4, PLAC_APP_SERVER_SELECTOR_ERR_NETWORK_UNAVAILABLE = 5, PLAC_APP_SERVER_SELECTOR_ERR_SOCKET_PERMISSION = 6, PLAC_APP_SERVER_SELECTOR_ERR_OUT_OF_MEMORY = 7, PLAC_APP_SERVER_SELECTOR_ERR_SOCKET = 8, PLAC_APP_SERVER_SELECTOR_ERR_THREAD_SPAWN = 9, } plac_app_server_selector_state; /* App.ServerSelector */ typedef struct { void* __private; plac_app_server_selector_state const state; /** * An array of server entries. */ plac_app_server_selector_entry **entries; unsigned int const entries_len; } plac_app_server_selector; /** * Resets state to NOT_LOADED and Releases server entries. * Triggers callbacks registered via "plac_app_server_selector_on_change". * Ongoing network operation won't be canceled, but that will not update the state * on complete. */ void plac_app_server_selector_reset(plac_app_server_selector*); /** * Starts loading of server lists on a network. * Triggers callbacks registered via "plac_app_server_selector_on_change". * If there is an ongoing loading task, this call will be ignored. */ void plac_app_server_selector_load(plac_app_server_selector*); /** * Appends a callback that will be invoked everytime "plac_app_server_selector" changes. */ void plac_app_server_selector_on_change(plac_app_server_selector*, void (*)(void*), void*); void plac_app_server_selector_on_change_disarm(plac_app_server_selector*, void(*)(void*)); /* App.ConnectionState */ typedef enum { /** * Not connected ("server" is NULL) or already connected ("server" is not NULL). */ PLAC_APP_CONNECTION_IDLE = 0, /** * Connecting to a server. */ PLAC_APP_CONNECTION_BUSY = 1, /** * Failed to connect due to unknown reasons. */ PLAC_APP_CONNECTION_ERR_UNEXPECTED = 2, /** * This being a dedicated error because this might be resolvable by a user. * Also, UI can serve a retry action to a user. */ PLAC_APP_CONNECTION_ERR_NETWORK_UNAVAILABLE = 3, /** * This being a dedicated error because this might be resolvable by a user. */ PLAC_APP_CONNECTION_ERR_SOCKET_PERMISSION = 4, /** * OOM. */ PLAC_APP_CONNECTION_ERR_OUT_OF_MEMORY = 5, /** * Catch-all error for socket related errors. * Internal socket errors returned by WebSocket will use * "PLAC_APP_CONNECTION_ERR_WEBSOCKET". */ PLAC_APP_CONNECTION_ERR_SOCKET = 6, /** * Roon Server's registry did not return healty response. */ PLAC_APP_CONNECTION_ERR_REGISTRY_DOWN = 7, /** * Unable to register Roon Extension on a server. */ PLAC_APP_CONNECTION_ERR_FAILED_TO_REGISTER = 8, /** * Unable to spawn a worker thread. */ PLAC_APP_CONNECTION_ERR_THREAD_SPAWN = 9, /** * Catch-all error for WebSocket related errors. */ PLAC_APP_CONNECTION_ERR_WEBSOCKET = 10, /** * No server matches the given ID. */ PLAC_APP_CONNECTION_ERR_NOT_FOUND = 11, } plac_app_connection_state; /* App */ typedef struct { void* __private; /** * Non NULL-able. */ plac_app_server_selector const * const server_selector; /** * Describes "server"'s current state. * * If "server" is non-NULL and this field is "_BUSY", then it means the app is * refreshing server state or connecting to an another server. */ plac_app_connection_state const connection; /** * NULL by default. Call "plac_app_connect" to fill-in. */ plac_app_server const * const server; } plac_app; /** * Creates a new app instance. This merely creates a struct: caller must invoke * functions against the struct's child fields, such as "plac_app_server_selector_load". * Returns NULL on out-of-memory. */ plac_app *plac_app_new(); void plac_app_destroy(plac_app*); /** * Connects to a server. */ void plac_app_connect(plac_app*, char *server_id, unsigned int server_id_len, char *saved_token, unsigned int saved_token_len); /** * Event fired when "server" field changes (connect/disconnect). */ void plac_app_on_server_change(plac_app*, void (*)(void*), void*); void plac_app_on_server_change_disarm(plac_app*, void (*)(void*)); /** * Event fired when "connection" field changes. */ void plac_app_on_connection_change(plac_app*, void (*)(void*), void*); void plac_app_on_connection_change_disarm(plac_app*, void (*)(void*)); /* Application */ // TODO: Remove me typedef struct { struct sockaddr __addr; void *__conn;
-
-
-
@@ -19,11 +19,54 @@pub const server = @import("./server.zig"); pub const Application = @import("./application.zig").Application; pub const App = @import("./App.zig"); pub const services = struct { pub const Transport = @import("./roon/services/transport.zig").TransportService; }; // Exports C APIs. For consistency, export order should match to one of header file. comptime { @export(&App.Server.Zone.CApi.onChange, .{ .name = "plac_app_server_zone_on_change" }); @export(&App.Server.Zone.CApi.onChangeDisarm, .{ .name = "plac_app_server_zone_on_change_disarm", }); @export(&App.Server.Zone.CApi.onDelete, .{ .name = "plac_app_server_zone_on_delete" }); @export(&App.Server.Zone.CApi.onDeleteDisarm, .{ .name = "plac_app_server_zone_on_delete_disarm", }); @export(&App.Server.CApi.loadZones, .{ .name = "plac_app_server_load_zones" }); @export(&App.Server.CApi.onZoneAdd, .{ .name = "plac_app_server_on_zone_add" }); @export(&App.Server.CApi.onZoneAddDisarm, .{ .name = "plac_app_server_on_zone_add_disarm" }); @export(&App.Server.CApi.onZoneListLoadingChange, .{ .name = "plac_app_server_on_zone_list_loading_change", }); @export(&App.Server.CApi.onZoneListLoadingChangeDisarm, .{ .name = "plac_app_server_on_zone_list_loading_change_disarm", }); @export(&App.ServerSelector.CApi.reset, .{ .name = "plac_app_server_selector_reset" }); @export(&App.ServerSelector.CApi.load, .{ .name = "plac_app_server_selector_load" }); @export(&App.ServerSelector.CApi.onChange, .{ .name = "plac_app_server_selector_on_change" }); @export( &App.ServerSelector.CApi.onChangeDisarm, .{ .name = "plac_app_server_selector_on_change_disarm" }, ); @export(&App.CApi.new, .{ .name = "plac_app_new" }); @export(&App.CApi.destroy, .{ .name = "plac_app_destroy" }); @export(&App.CApi.connect, .{ .name = "plac_app_connect" }); @export(&App.CApi.onServerChange, .{ .name = "plac_app_on_server_change" }); @export(&App.CApi.onServerChangeDisarm, .{ .name = "plac_app_on_server_change_disarm" }); @export(&App.CApi.onConnectionChange, .{ .name = "plac_app_on_connection_change" }); @export(&App.CApi.onConnectionChangeDisarm, .{ .name = "plac_app_on_connection_change_disarm" }); // -- Old API -- @export(&server.Server.dupe, .{ .name = "plac_server_dupe" }); @export(&server.Server.free, .{ .name = "plac_server_free" });
-
-
-
@@ -38,7 +38,8 @@pub const Connection = struct { allocator: std.mem.Allocator, thread_safe_allocator: *std.heap.ThreadSafeAllocator, ws: websocket.Client, // Making this non-pointer causes invalid read/write when thread terminates. ws: *websocket.Client, addr: []const u8, rng: std.Random.Xoshiro256, thread: ?std.Thread = null,
-
@@ -69,7 +70,8 @@ const port_start = std.mem.lastIndexOfScalar(u8, addr_string, ':') orelse {unreachable; }; var client = websocket.Client.init(allocator, .{ var client = try allocator.create(websocket.Client); client.* = websocket.Client.init(allocator, .{ .port = address.getPort(), .host = addr_string[0..port_start], }) catch return InitError.WebSocketClientCreationError;
-
@@ -109,17 +111,28 @@ }pub fn deinit(self: *Connection) void { std.log.debug("Closing WebSocket connection...", .{}); self.ws.close(.{}) catch |err| { std.log.warn("Failed to close WebSocket connection, proceeding: {s}", .{@errorName(err)}); // websocket.zig cannot terminate ongoing read via ".close()". // Manually shutting-down socket seems necessary. // Related: https://github.com/karlseguin/websocket.zig/issues/46 std.posix.shutdown(self.ws.stream.stream.handle, .recv) catch |err| { std.log.warn("Failed to shutdown socket handle of WebSocket: {s}", .{@errorName(err)}); }; self.ws.close(.{ .code = 4000, .reason = "bye" }) catch |err| { std.log.warn("Unable to close WebSocket client, proceeding: {s}", .{@errorName(err)}); }; if (self.thread) |thread| { std.log.debug("Waiting WebSocket thread to terminate...", .{}); // Wait for read thread to terminate. thread.join(); self.thread = null; } std.log.debug("Releasing WebSocket resources...", .{}); self.ws.deinit(); self.allocator.destroy(self.ws); std.log.debug("Releasing stale responses...", .{}); self.responses_mutex.lock(); var iter = self.responses.iterator(); while (iter.next()) |entry| {
-
@@ -130,11 +143,11 @@ }self.responses.deinit(); self.responses_mutex.unlock(); std.log.debug("Releasing connection objects...", .{}); 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 {
-
@@ -169,16 +182,9 @@ }}; fn readLoop(conn: *Connection, on_request: RequestHandler) void { conn.ws.readTimeout(1_000) catch |err| { std.log.err("Unable to set WebSocket read timeout: {s}", .{@errorName(err)}); return; }; while (true) { const msg = conn.ws.read() catch return orelse continue; const msg = (conn.ws.read() catch return) orelse unreachable; defer conn.ws.done(msg); std.log.debug("Received WebSocket message: type={s}", .{@tagName(msg.type)}); switch (msg.type) { // NOTE: roon-node-api does not check whether message is binaryType.
-
@@ -236,6 +242,7 @@ const bytes = buffer.toOwnedSlice() catch |err| {std.log.warn("Unable to prepare response bytes: {s}\n", .{@errorName(err)}); continue; }; defer conn.allocator.free(bytes); conn.ws.writeBin(bytes) catch |err| { std.log.warn("Failed to write response message: {s}\n", .{@errorName(err)});
-
@@ -246,7 +253,7 @@ .ping => conn.ws.writePong(msg.data) catch {},.pong => {}, .close => { conn.ws.close(.{}) catch return; break; return; }, } }
-
-
-
@@ -0,0 +1,280 @@// 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 sood = @import("sood"); const udp_dst = std.net.Address.initIp4( sood.discovery.multicast_ipv4_address, sood.discovery.udp_port, ); const Options = struct { /// How many times will I send discovery query via UDP multicast? /// As discovery is done via UDP, there is a chance of discovery query /// will be lost. To accommodate the nature of UDP, this module sends /// more than one query with timeouts (`receive_timeout_ms`) in-between. /// /// Maximum of total discovery duration is `send_count * receive_timeout_ms`. send_count: usize = 4, /// How long should I wait for a response for discovery query? /// /// Must be greater than 0. /// /// Maximum of total discovery duration is `send_count * receive_timeout_ms`. receive_timeout_ms: u32 = 1_300, }; const StaticError = error{ /// Unexpected error happened. Unknown, /// The program has no permission for socket access. /// Requires user to grant required permission for the program. SocketPermissionDenied, /// Unable to create or configure UDP socket. /// Restarting the program or a system _may_ help. /// It's also possible that the system does not support required socket features, /// but this should be quite rare: I belive socket features this module uses are /// supported in every modern POSIX-compliant OS. SocketCreationError, /// Unable to send discovery query. /// Perhaps multicast is disallowed? User intervention is necessary in that case. UDPSendError, /// Unable to receive UDP message. /// As UDP socket is connection-less, this should be extremely rare. /// It could be kernel-level network or memory error. UDPRecvError, /// A value of `Options.receive_timeout_ms` being `0`. InvalidReceiveWindow, /// Network interface is down, kernel error, etc. NetworkUnavailable, } || std.mem.Allocator.Error; pub fn Error(comptime Server: type) type { return StaticError || Server.FromSoodResponseError; } /// List Roon Servers on a network. pub fn scan( comptime Server: type, allocator: std.mem.Allocator, opts: Options, ) Error(Server)!std.StringHashMap(Server) { const sockfd = try createSocket(opts); defer std.posix.close(sockfd); var servers = std.StringHashMap(Server).init(allocator); errdefer servers.deinit(); for (0..opts.send_count) |_| { try sendDiscoveryQuery(sockfd); while (true) { std.log.debug("Waiting for UDP message...", .{}); // Discovery response from servers usually fits under 300 bytes. // Extra bytes for safety. var received: [512]u8 = undefined; var src: std.net.Address = undefined; var src_len: std.posix.socklen_t = udp_dst.getOsSockLen(); const size = std.posix.recvfrom( sockfd, &received, 0, &src.any, &src_len, ) catch |err| switch (err) { std.posix.RecvFromError.WouldBlock => { std.log.debug("UDP read timeout.", .{}); break; }, std.posix.RecvFromError.MessageTooBig => { std.log.warn("Unable to read UDP message (message too big)", .{}); continue; }, else => return StaticError.UDPRecvError, }; std.log.debug("Got UDP message.", .{}); const response = sood.discovery.Response.parse(received[0..size]) catch |err| { std.log.warn( "Unable to parse received UDP message as SOOD message: {s}", .{@errorName(err)}, ); // Non-SOOD message. Unlikely but technically possible. continue; }; const stale = servers.get(response.unique_id); defer if (stale) |server| { server.deinit(); }; const server = try Server.fromSoodResponse(allocator, src, &response); errdefer server.deinit(); try servers.put(server.getKey(), server); } } return servers; } pub fn resolve( comptime Server: type, allocator: std.mem.Allocator, unique_id: []const u8, opts: Options, ) Error(Server)!?Server { const sockfd = try createSocket(opts); defer std.posix.close(sockfd); for (0..opts.send_count) |i| { std.log.debug("Sending discovery query... ({d})", .{i}); try sendDiscoveryQuery(sockfd); while (true) { std.log.debug("Waiting for UDP message...", .{}); // Discovery response from servers usually fits under 300 bytes. // Extra bytes for safety. var received: [512]u8 = undefined; var src: std.net.Address = undefined; var src_len: std.posix.socklen_t = udp_dst.getOsSockLen(); const size = std.posix.recvfrom( sockfd, &received, 0, &src.any, &src_len, ) catch |err| switch (err) { std.posix.RecvFromError.WouldBlock => { std.log.debug("UDP read timeout.", .{}); break; }, std.posix.RecvFromError.MessageTooBig => { std.log.warn("Unable to read UDP message (message too big)", .{}); continue; }, else => return StaticError.UDPRecvError, }; std.log.debug("Got UDP message.", .{}); const response = sood.discovery.Response.parse(received[0..size]) catch |err| { std.log.warn( "Unable to parse received UDP message as SOOD message: {s}", .{@errorName(err)}, ); // Non-SOOD message. Unlikely but technically possible. continue; }; if (std.mem.eql(u8, response.unique_id, unique_id)) { return try Server.fromSoodResponse(allocator, src, &response); } } } return null; } fn createSocket(opts: Options) StaticError!std.posix.socket_t { std.log.debug("Opening UDP socket...", .{}); const sockfd = std.posix.socket(std.posix.AF.INET, std.posix.SOCK.DGRAM, 0) catch |err| { return switch (err) { std.posix.SocketError.PermissionDenied => StaticError.SocketPermissionDenied, std.posix.SocketError.SystemFdQuotaExceeded, std.posix.SocketError.ProcessFdQuotaExceeded, => StaticError.SocketCreationError, std.posix.SocketError.SystemResources => StaticError.OutOfMemory, else => StaticError.SocketCreationError, }; }; errdefer std.posix.close(sockfd); std.posix.setsockopt( sockfd, std.posix.SOL.SOCKET, std.posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1)), ) catch |err| return switch (err) { std.posix.SetSockOptError.PermissionDenied => StaticError.SocketPermissionDenied, std.posix.SetSockOptError.SystemResources => StaticError.OutOfMemory, else => StaticError.SocketCreationError, }; const sec = std.math.divFloor(u32, opts.receive_timeout_ms, 1_000) catch { return StaticError.InvalidReceiveWindow; }; const usec = @min( std.math.maxInt(i32), 1_000 * (std.math.rem(u32, opts.receive_timeout_ms, 1_000) catch { return StaticError.InvalidReceiveWindow; }), ); std.log.debug("Setting UDP read timeout to {d}ms ({d}sec, {d}usec)", .{ opts.receive_timeout_ms, sec, usec, }); const timeout = std.posix.timeval{ .sec = sec, .usec = usec }; std.posix.setsockopt( sockfd, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, &std.mem.toBytes(timeout), ) catch |err| return switch (err) { std.posix.SetSockOptError.PermissionDenied => StaticError.SocketPermissionDenied, std.posix.SetSockOptError.SystemResources => StaticError.OutOfMemory, else => StaticError.SocketCreationError, }; return sockfd; } fn sendDiscoveryQuery(sockfd: std.posix.socket_t) StaticError!void { std.log.debug("Sending server discovery message to {}", .{udp_dst}); _ = std.posix.sendto( sockfd, sood.discovery.Query.prebuilt, 0, &udp_dst.any, udp_dst.getOsSockLen(), ) catch |err| { std.log.err("Failed to send discovery message: {s}", .{@errorName(err)}); return switch (err) { std.posix.SendToError.NetworkSubsystemFailed, std.posix.SendToError.NetworkUnreachable, => StaticError.NetworkUnavailable, else => StaticError.UDPSendError, }; }; }
-