Changes
30 changed files (+103/-4059)
-
-
@@ -20,7 +20,7 @@ .fingerprint = 0xbc872ed057582c78,.minimum_zig_version = "0.14.0", .dependencies = .{ .plac_core = .{ .path = "../old_core", .path = "../core", }, .clap = .{ .url = "https://github.com/Hejsil/zig-clap/archive/refs/tags/0.10.0.tar.gz",
-
-
cli/src/cb.zig (deleted)
-
@@ -1,70 +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 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(); } }; }
-
-
cli/src/commands/connect.zig (deleted)
-
@@ -1,108 +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 cb = @import("../cb.zig"); const path = @import("../path.zig"); const ExitCode = @import("../exit.zig").ExitCode; const parsers = .{ .server_id = clap.parsers.string, }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\<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, parsers, iter, .{ .diagnostic = &diag, .allocator = allocator, }) catch |err| { diag.report(std.io.getStdErr().writer(), err) catch {}; return ExitCode.incorrect_usage; }; defer res.deinit(); if (res.args.help > 0) { clap.help(std.io.getStdOut().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.ok; } const server_id: []const u8 = res.positionals[0] orelse { std.log.err("server_id is required", .{}); clap.help(std.io.getStdErr().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.incorrect_usage; }; var app = core.App.CApi.new() orelse { std.log.err("Unable to create an app: out of memory", .{}); return ExitCode.out_of_memory; }; defer app.destroy(); const state_file_path = path.getStateFilePath(allocator) catch |err| { std.log.err("Unable to resolve state file path: {s}", .{@errorName(err)}); return ExitCode.not_ok; }; defer allocator.free(state_file_path); app.setStatePath(state_file_path.ptr, state_file_path.len); const OnConnectionChange = cb.Callback(struct {}); var on_connection_change = OnConnectionChange.init(); app.onConnectionChange(OnConnectionChange.function, on_connection_change.userdata()); const server_id_z = allocator.dupeZ(u8, server_id) catch { std.log.err("Unable to allocate CString for server ID: out of memory", .{}); return ExitCode.out_of_memory; }; defer allocator.free(server_id_z); app.connect(server_id_z.ptr, server_id_z.len, null, 0); while (true) { on_connection_change.wait(); switch (app.connection) { core.App.CApi.ConnectionState.busy => continue, core.App.CApi.ConnectionState.idle => break, else => { std.log.err("Unable to connect: {s}", .{@tagName(app.connection)}); return ExitCode.not_ok; }, } } if (app.server) |server| { std.log.info("Connected to {s}", .{server.name}); return ExitCode.ok; } else { std.log.err("Unable to connect to {s} (server = null)", .{server_id}); return ExitCode.not_ok; } }
-
-
cli/src/commands/playback.zig (deleted)
-
@@ -1,152 +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 cb = @import("../cb.zig"); const path = @import("../path.zig"); const ExitCode = @import("../exit.zig").ExitCode; const parsers = .{ .string = clap.parsers.string, }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\-z, --zone <string> Name of zone to display playback state. \\ ); pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator) ExitCode { var diag = clap.Diagnostic{}; var res = clap.parseEx(clap.Help, ¶ms, parsers, iter, .{ .diagnostic = &diag, .allocator = allocator, }) catch |err| { diag.report(std.io.getStdErr().writer(), err) catch {}; return ExitCode.incorrect_usage; }; defer res.deinit(); if (res.args.help > 0) { clap.help(std.io.getStdOut().writer(), clap.Help, ¶ms, .{}) catch { return ExitCode.stdout_write_failed; }; return ExitCode.ok; } const name = res.args.zone orelse { std.log.err("--zone is required.", .{}); return ExitCode.incorrect_usage; }; var app = core.App.CApi.new() orelse { std.log.err("Unable to create an app: out of memory", .{}); return ExitCode.out_of_memory; }; defer app.destroy(); const state_file_path = path.getStateFilePath(allocator) catch |err| { std.log.err("Unable to resolve state file path: {s}", .{@errorName(err)}); return ExitCode.not_ok; }; defer allocator.free(state_file_path); app.setStatePath(state_file_path.ptr, state_file_path.len); const OnRestoreComplete = cb.Callback(struct {}); var on_restore_complete = OnRestoreComplete.init(); app.onRestoreComplete(OnRestoreComplete.function, on_restore_complete.userdata()); const OnConnectionChange = cb.Callback(struct {}); var on_connection_change = OnConnectionChange.init(); app.onConnectionChange(OnConnectionChange.function, on_connection_change.userdata()); app.restoreState(); on_restore_complete.wait(); if (app.connection != .busy) { std.log.err("Not connected, run `connect` command first", .{}); return ExitCode.not_ok; } const server = server: while (true) { on_connection_change.wait(); switch (app.connection) { core.App.CApi.ConnectionState.busy => continue, core.App.CApi.ConnectionState.idle => { if (app.server) |s| { break :server s; } else { std.log.err("Connection lost", .{}); return ExitCode.not_ok; } }, else => { std.log.err("Unable to connect: {s}", .{@tagName(app.connection)}); return ExitCode.not_ok; }, } }; 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(); 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, }; }, } } }
-
-
-
@@ -18,7 +18,6 @@ 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 {
-
@@ -60,96 +59,83 @@ };return ExitCode.ok; } var app = core.App.CApi.new() orelse { std.log.err("Unable to create an app: out of memory", .{}); const result = core.discovery.scan() orelse { std.log.err("Unable to scan: out of memory", .{}); return ExitCode.out_of_memory; }; defer app.destroy(); defer result.release(); const OnChange = cb.Callback(struct {}); var on_change = OnChange.init(); switch (result.code) { .ok => {}, .out_of_memory => { std.log.err("Failed to scan: out of memory", .{}); return ExitCode.out_of_memory; }, else => |code| { std.log.err("Failed to scan: {s}", .{@tagName(code)}); return ExitCode.not_ok; }, } app.server_selector.onChange(OnChange.function, on_change.userdata()); app.server_selector.load(); while (true) { on_change.wait(); std.log.debug("ServerSelector.state changed to {s}", .{ @tagName(app.server_selector.state), }); 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 servers = result.servers_ptr[0..result.servers_len]; const stdout = std.io.getStdOut().writer(); if (servers.len == 0) { std.log.info("No servers found.", .{}); return ExitCode.ok; } 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 stdout = std.io.getStdOut().writer(); const version = allocator.dupe(u8, server.getVersion()) catch { return ExitCode.out_of_memory; }; std.mem.replaceScalar(u8, version, '\t', ' '); switch (res.args.format orelse .text) { .text => { for (servers) |server| { stdout.print("ID={s} IP={} VERSION=\"{s}\"\n", .{ server.id, server.internal.address, server.version, }) 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 (servers) |server| { const name = allocator.dupe(u8, std.mem.span(server.name)) catch { return ExitCode.out_of_memory; }; std.mem.replaceScalar(u8, name, '\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; }; } }, } const version = allocator.dupe(u8, std.mem.span(server.version)) catch { return ExitCode.out_of_memory; }; std.mem.replaceScalar(u8, version, '\t', ' '); return ExitCode.ok; }, else => |err| { std.log.err("Failed to scan: {s}", .{@tagName(err)}); return ExitCode.not_ok; }, } stdout.print("{s}\t{s}\t{}\t{s}\n", .{ server.id, name, // Only CLI needs this... handling POSIX addr is messy. server.internal.address, version, }) catch { return ExitCode.stdout_write_failed; }; } }, .jsonl => { for (servers) |server| { stdout.print("{}\n", .{ std.json.fmt(server, .{ .whitespace = .minified }), }) catch { return ExitCode.stdout_write_failed; }; } }, } return ExitCode.ok; }
-
-
-
@@ -20,9 +20,7 @@ const build_config = @import("build_config");const core = @import("core"); const ExitCode = @import("./exit.zig").ExitCode; const connect = @import("./commands/connect.zig"); const server = @import("./commands/server.zig"); const playback = @import("./commands/playback.zig"); const version = "0.0.0";
-
@@ -46,9 +44,7 @@ }const Commands = enum { server, playback, version, connect, }; const global_parser = .{
-
@@ -66,8 +62,6 @@ \\<command>\\Available commands: \\* version ... Prints version to stdout and exits. \\* server ... Lists or gets information of Roon Server on network \\* playback ... Display playback state of a zone. \\* connect ... Connects to Roon Server and save the state to a disk. \\ );
-
@@ -123,7 +117,5 @@ .version => {try std.fmt.format(std.io.getStdOut().writer(), "{s}\n", .{version}); return @intFromEnum(ExitCode.ok); }, .connect => return @intFromEnum(connect.run(allocator, &iter)), .playback => return @intFromEnum(playback.run(allocator, &iter)), } }
-
-
cli/src/path.zig (deleted)
-
@@ -1,25 +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"); /// Caller owns the returned memory. pub fn getStateFilePath(allocator: std.mem.Allocator) ![:0]const u8 { const cwd = try std.fs.cwd().realpathAlloc(allocator, "."); defer allocator.free(cwd); return try std.fs.path.joinZ(allocator, &.{ cwd, ".roon.json" }); }
-
-
-
@@ -44,6 +44,17 @@break :websocket dep.module("websocket"); }; // Zig module { const mod = b.addModule("core", .{ .root_source_file = b.path("src/main.zig"), }); mod.addImport("sood", sood); mod.addImport("moo", moo); mod.addImport("websocket", websocket); } // Static library for GLib { const linkage = b.option(
-
-
-
@@ -109,6 +109,25 @@ allocator.destroy(self);} } /// For CLI pub fn jsonStringify(self: *const Server, jws: anytype) !void { try jws.beginObject(); try jws.objectField("unique_id"); try jws.write(self.internal.id); try jws.objectField("name"); try jws.write(self.internal.name); try jws.objectField("version"); try jws.write(self.internal.version); try jws.objectField("address"); try jws.print("\"{}\"", .{self.internal.address}); try jws.endObject(); } pub fn export_capi() void { @export(&retain, .{ .name = std.fmt.comptimePrint("{s}_retain", .{cname}) }); @export(&release, .{ .name = std.fmt.comptimePrint("{s}_release", .{cname}) });
-
-
-
@@ -14,9 +14,9 @@ // limitations under the License.// // SPDX-License-Identifier: Apache-2.0 const connection = @import("./connection.zig"); const discovery = @import("./discovery.zig"); const transport = @import("./transport.zig"); pub const connection = @import("./connection.zig"); pub const discovery = @import("./discovery.zig"); pub const transport = @import("./transport.zig"); pub fn export_capi() void { connection.export_capi();
-
-
old_core/build.zig (deleted)
-
@@ -1,168 +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 //! This file defines how `zig build` command behaves. //! Run `zig build --help` for available subcommands and options. //! //! Learn more at //! https://ziglang.org/learn/build-system/ const std = @import("std"); pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); const sood = sood: { const dep = b.dependency("sood", .{}); 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", .{ .root_source_file = b.path("src/lib.zig"), }); mod.addImport("sood", sood); mod.addImport("moo", moo); mod.addImport("websocket", websocket); } // XCFramework // https://mitchellh.com/writing/zig-and-swiftui if (target.result.os.tag.isDarwin()) { const arm = b.addLibrary(.{ .name = "plac_core_arm", .linkage = .static, .root_module = b.createModule(.{ .root_source_file = b.path("src/lib.zig"), .target = b.resolveTargetQuery(.{ .os_tag = .macos, .cpu_arch = .aarch64, }), .optimize = optimize, }), }); // While I could have dedicated unit just for copying, this is simpler and easier. arm.installHeader(b.path("src/lib.h"), "plac_core.h"); arm.installHeader(b.path("src/lib.modulemap"), "module.modulemap"); const intel = b.addLibrary(.{ .name = "plac_core_intel", .linkage = .static, .root_module = b.createModule(.{ .root_source_file = b.path("src/lib.zig"), .target = b.resolveTargetQuery(.{ .os_tag = .macos, .cpu_arch = .x86_64, }), .optimize = optimize, }), }); inline for (.{ arm, intel }) |compile| { compile.linkLibC(); // Without bundling those, XCode unable to resolve symbols. 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: { const lipo = b.addSystemCommand(&.{"lipo"}); lipo.addArg("-create"); lipo.addArg("-output"); const output = lipo.addOutputFileArg("libplac_core.a"); lipo.addFileArg(arm.getEmittedBin()); lipo.addFileArg(intel.getEmittedBin()); break :universal output; }; const xcframework = xcframework: { const xcodebuild = b.addSystemCommand(&.{"xcodebuild"}); xcodebuild.addArg("-create-xcframework"); xcodebuild.addArg("-library"); xcodebuild.addFileArg(universal_lib); xcodebuild.addArg("-headers"); xcodebuild.addDirectoryArg(arm.getEmittedIncludeTree()); xcodebuild.addArg("-output"); break :xcframework xcodebuild.addOutputDirectoryArg("PlacCore.xcframework"); }; const step = b.step("xcframework", "Build xcframework file for XCode"); step.dependOn(&b.addInstallDirectory(.{ .source_dir = xcframework, .install_dir = .{ .custom = "xcframeworks" }, .install_subdir = "PlacCore.xcframework", }).step); } // Static library (C API) { const linkage = b.option( std.builtin.LinkMode, "linkage", "Link mode of the generated library file", ) orelse .static; const lib = b.addLibrary(.{ .name = "plac_core", .linkage = linkage, .root_module = b.createModule(.{ .root_source_file = b.path("src/lib.zig"), .target = target, .optimize = optimize, }), }); 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"); lib.installHeader(b.path("src/lib.vapi"), "plac_core.vapi"); b.installArtifact(lib); } }
-
-
old_core/build.zig.zon (deleted)
-
@@ -1,40 +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 .{ .name = .plac_core, .version = "0.0.0", .fingerprint = 0xc2c55a63ff4f6fb3, .minimum_zig_version = "0.14.0", .dependencies = .{ .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", "build.zig.zon", "src/", }, }
-
-
old_core/src/App.zig (deleted)
-
@@ -1,631 +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 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 State = @import("./App/State.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 OnRestoreComplete = 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: *const @This()) void { self.allocator.free(self.version); self.allocator.free(self.name); self.allocator.free(self.id); } pub fn getKey(self: *const @This()) []const u8 { return self.id; } }; allocator: std.mem.Allocator, pool: std.Thread.Pool = undefined, on_server_change: OnServerChange.Store, on_connection_change: OnConnectionChange.Store, on_restore_complete: OnRestoreComplete.Store, capi_lock: std.Thread.Mutex, state_file_path: ?[]const u8 = null, 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), .on_restore_complete = OnRestoreComplete.Store.init(allocator), .capi_lock = std.Thread.Mutex{}, }; } pub fn deinit(self: *App) void { self.pool.deinit(); if (self.state_file_path) |path| { self.allocator.free(path); } self.on_restore_complete.deinit(); 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 const InitError = error{ PoolInitError, } || std.mem.Allocator.Error; pub fn init(allocator: std.mem.Allocator) InitError!CApi { const internal = try allocator.create(App); errdefer allocator.destroy(internal); internal.* = App.init(allocator); internal.pool.init(.{ .allocator = allocator, .n_jobs = 4 }) catch |err| { std.log.err("Unable to initialize thread pool: {s}", .{@errorName(err)}); return InitError.PoolInitError; }; errdefer internal.deinit(); const server_selector = try allocator.create(ServerSelector.CApi); errdefer allocator.destroy(server_selector); server_selector.* = try ServerSelector.CApi.init(allocator, &internal.pool); errdefer server_selector.deinit(allocator); return .{ .internal = internal, .server_selector = server_selector, }; } pub fn new() callconv(.C) ?*CApi { const capi = std.heap.c_allocator.create(CApi) catch return null; capi.* = CApi.init(std.heap.c_allocator) catch { std.heap.c_allocator.destroy(capi); return null; }; return capi; } 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); allocator.destroy(self.server_selector); self.internal.deinit(); allocator.destroy(self.internal); self.internal = undefined; } pub fn destroy(self_ptr: ?*CApi) callconv(.C) void { const self = self_ptr orelse return; std.log.debug("Destroying application...", .{}); self.deinit(std.heap.c_allocator); std.heap.c_allocator.destroy(self); } pub fn setStatePath(self_ptr: ?*CApi, path_ptr: ?[*:0]const u8, path_len: usize) callconv(.C) void { const self = self_ptr orelse return; const path = if (path_ptr) |ptr| ptr[0..path_len] else { std.log.warn("A path given to plac_app_set_state_path is NULL", .{}); return; }; std.log.debug("Setting state path to {s}...", .{path}); self.internal.state_file_path = self.internal.allocator.dupe(u8, path) catch { std.log.err("Unable to clone state file path: out of memory", .{}); return; }; } pub fn restoreState(self_ptr: ?*CApi) callconv(.C) void { const self = self_ptr orelse return; const file_path = self.internal.state_file_path orelse { std.log.debug("No state file is configured, skipping restore phase", .{}); self.internal.on_restore_complete.runAll(.{}); return; }; if (!std.fs.path.isAbsolute(file_path)) { std.log.err("State file path is not absolute: {s}", .{file_path}); self.internal.on_restore_complete.runAll(.{}); return; } self.internal.pool.spawn(restoreWorker, .{ self, file_path }) catch |err| { std.log.err("Unable to spawn thread for restore job: {s}", .{@errorName(err)}); self.internal.on_restore_complete.runAll(.{}); return; }; } fn restoreWorker(self: *CApi, file_path: []const u8) void { defer self.internal.on_restore_complete.runAll(.{}); const file = std.fs.openFileAbsolute(file_path, .{}) catch |err| { switch (err) { error.FileNotFound => return, else => { std.log.err("Unable to open state file: {s}", .{@errorName(err)}); return; }, } }; var reader = std.json.reader(self.internal.allocator, file.reader()); defer reader.deinit(); const parsed = std.json.parseFromTokenSource( State, self.internal.allocator, &reader, .{}, ) catch |err| { std.log.err("Failed to parse state file: {s}", .{@errorName(err)}); return; }; defer parsed.deinit(); const conn = parsed.value.connection orelse { std.log.debug("No connection stored on state file, skipping", .{}); return; }; std.log.debug("Loaded state, connecting to {s}", .{conn.server_id}); self.connectInner(conn.server_id, conn.token); } fn saveState(self: *CApi, state: State) void { const file_path = self.internal.state_file_path orelse { std.log.debug("State file path is not set, skipping", .{}); return; }; std.log.debug("Saving state to {s}...", .{file_path}); const file = std.fs.createFileAbsolute(file_path, .{}) catch |err| { std.log.err("Unable to create or open {s}: {s}", .{ file_path, @errorName(err) }); return; }; defer file.close(); std.json.stringify(state, .{ .whitespace = .indent_tab }, file.writer()) catch |err| { std.log.err("Unable to write state to {s}: {s}", .{ file_path, @errorName(err) }); return; }; } pub fn connect( self_ptr: ?*CApi, server_id_ptr: [*: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; const server_id = server_id_ptr[0..server_id_len]; const saved_token = if (saved_token_ptr) |tok| tok[0..saved_token_len] else null; self.connectInner(server_id, saved_token); } fn connectInner(self: *CApi, server_id: []const u8, saved_token: ?[]const u8) void { if (self.connection == .busy) { return; } if (self.server) |server| { if (std.mem.eql(u8, server.id[0..server.id_len], server_id)) { return; } } { self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = .busy; self.internal.on_connection_change.runAll(.{}); } const server_id_t = self.internal.allocator.dupe(u8, server_id) 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; }; const saved_token_t = if (saved_token) |t| self.internal.allocator.dupe(u8, t) catch { self.internal.allocator.free(server_id_t); self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = .err_out_of_memory; self.internal.on_connection_change.runAll(.{}); return; } else null; self.internal.pool.spawn(connectWorker, .{ self, server_id_t, saved_token_t }) catch { self.internal.allocator.free(server_id_t); if (saved_token_t) |t| { self.internal.allocator.free(t); } self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); self.connection = .err_thread_spawn; self.internal.on_connection_change.runAll(.{}); return; }; } pub fn lock(capi_ptr: ?*CApi) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.capi_lock.lock(); } pub fn unlock(capi_ptr: ?*CApi) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.capi_lock.unlock(); } 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, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_server_change.remove(OnServerChange.init(cb, userdata)); } 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, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_connection_change.remove(OnConnectionChange.init(cb, userdata)); } pub fn onRestoreComplete(capi_ptr: ?*CApi, cb: OnRestoreComplete.Fn, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; // TODO: Notify error to caller capi.internal.on_restore_complete.add(OnRestoreComplete.init(cb, userdata)) catch {}; } pub fn onRestoreCompleteDisarm(capi_ptr: ?*CApi, cb: OnRestoreComplete.Fn, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_restore_complete.remove(OnRestoreComplete.init(cb, userdata)); } }; fn connectWorker(self: *CApi, server_id: []const u8, saved_token: ?[]const u8) void { defer { self.internal.allocator.free(server_id); if (saved_token) |t| { self.internal.allocator.free(t); } } 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(), &self.internal.pool, 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); self.saveState(.{ .connection = .{ .server_id = server_id, .token = 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, &self.internal.pool, &self.internal.capi_lock, ) 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, &self.internal.pool, 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); self.saveState(.{ .connection = .{ .server_id = info.id, .token = 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, &self.internal.pool, &self.internal.capi_lock, ) 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, pool: *std.Thread.Pool, 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, pool); 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 }; }
-
-
old_core/src/App/Server.zig (deleted)
-
@@ -1,282 +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 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 Zones = std.StringHashMap(Zone.CApi); const OnZoneAdd = callback.Callback(*const Zone.CApi); const OnZoneListLoadingChange = callback.Callback(struct {}); allocator: std.mem.Allocator, address: std.net.Address, pool: *std.Thread.Pool, zones: Zones, on_zone_add: OnZoneAdd.Store, on_zone_list_loading_change: OnZoneListLoadingChange.Store, // Unowned. Caller manages the resource. conn: *connection.Connection, // Unowned. Caller manages the resource. capi_lock: *std.Thread.Mutex, fn deinit(self: *Self) void { var iter = self.zones.iterator(); while (iter.next()) |entry| { entry.value_ptr.deinit(self.allocator); self.allocator.free(entry.key_ptr.*); } self.zones.deinit(); 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, // Each element is a reference to an item in `internal.zones`. 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, pool: *std.Thread.Pool, capi_lock: *std.Thread.Mutex, ) std.mem.Allocator.Error!CApi { const internal = try allocator.create(Self); errdefer allocator.destroy(internal); internal.* = .{ .allocator = allocator, .address = address, .conn = conn, .pool = pool, .capi_lock = capi_lock, .zones = Zones.init(allocator), .on_zone_add = OnZoneAdd.Store.init(allocator), .on_zone_list_loading_change = OnZoneListLoadingChange.Store.init(allocator), }; 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 { self.internal.allocator.free(self.zones[0..self.zones_len]); 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); self.internal = undefined; } 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; capi.internal.pool.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; }; } fn loadZonesWorker(self: *CApi) void { self.loadZonesWorkerInnerCont() catch |err| { std.log.err("Failed to subscribe to zone status: {s}", .{@errorName(err)}); self.zones_loading = switch (err) { error.OutOfMemory => ZoneListLoading.err_out_of_memory, else => ZoneListLoading.err_unexpected, }; self.internal.on_zone_list_loading_change.runAll(.{}); }; } fn loadZonesWorkerInnerCont(self: *CApi) !void { var subscription = Transport.ZoneChangeSubscription.init(self.internal.conn); var iter = try subscription.iter(self.internal.allocator); while (try iter.next(self.internal.allocator)) |event| { defer event.deinit(); self.internal.capi_lock.lock(); defer self.internal.capi_lock.unlock(); for (event.added_zones) |zone| { std.log.debug( "Adding zone \"{s}\" ({s})...", .{ zone.display_name, zone.zone_id }, ); const key = self.internal.zones.getKey(zone.zone_id) orelse try self.internal.allocator.dupe(u8, zone.zone_id); var prev = try self.internal.zones.fetchPut( key, try Zone.CApi.init(self.internal.allocator, &zone), ); if (prev) |*entry| { entry.value.deinit(self.internal.allocator); } } for (event.changed_zones) |zone| { std.log.debug( "Replacing zone \"{s}\" ({s})...", .{ zone.display_name, zone.zone_id }, ); const key = self.internal.zones.getKey(zone.zone_id) orelse try self.internal.allocator.dupe(u8, zone.zone_id); var prev = try self.internal.zones.fetchPut( key, try Zone.CApi.init(self.internal.allocator, &zone), ); if (prev) |*entry| { entry.value.deinit(self.internal.allocator); } } for (event.removed_ids) |id| { if (self.internal.zones.getEntry(id)) |entry| { std.log.debug("Removing zone \"{s}\" ({s})...", .{ entry.value_ptr.name[0..entry.value_ptr.name_len], id, }); const key = entry.key_ptr.*; entry.value_ptr.deinit(self.internal.allocator); // The `if` conditional already checks whether the key exists. _ = self.internal.zones.remove(id); // `entry.key_ptr` changes at the call of `remove`. // We have to store a pointer for the underlying buffer before that. self.internal.allocator.free(key); } } const zones_len = self.internal.zones.count(); const zones = try self.internal.allocator.alloc(*Zone.CApi, zones_len); var zone_iter = self.internal.zones.iterator(); var i: usize = 0; while (zone_iter.next()) |entry| { zones[i] = entry.value_ptr; i += 1; } self.internal.allocator.free(self.zones[0..self.zones_len]); self.zones = zones.ptr; self.zones_len = zones.len; self.zones_loading = .loaded; self.internal.on_zone_list_loading_change.runAll(.{}); } std.log.debug("Zone loading subscription finished", .{}); } 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, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_zone_add.remove(OnZoneAdd.init(cb, userdata)); } 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, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_zone_list_loading_change.remove(OnZoneListLoadingChange.init(cb, userdata)); } };
-
-
old_core/src/App/Server/Zone.zig (deleted)
-
@@ -1,113 +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 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: *const 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: *const 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); return .{ .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, }, }; } 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); self.internal = undefined; } 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, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_change_callbacks.remove(OnChange.init(cb, userdata)); } 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, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_delete_callbacks.remove(OnDelete.init(cb, userdata)); } };
-
-
old_core/src/App/ServerSelector.zig (deleted)
-
@@ -1,206 +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 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, pool: *std.Thread.Pool, has_loaded_once: bool = false, /// Acts as backing buffer for `CApi.entries`. For this reason, `CApi` manages this /// struct's memory. scan_result: ?std.StringHashMap(Entry.CApi) = null, pub fn init(allocator: std.mem.Allocator, pool: *std.Thread.Pool) Self { return .{ .allocator = allocator, .on_change_callbacks = OnChange.Store.init(allocator), .capi_lock = std.Thread.Mutex{}, .pool = pool, }; } pub fn deinit(self: *const 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, pool: *std.Thread.Pool) std.mem.Allocator.Error!CApi { const internal = try allocator.create(Self); errdefer allocator.destroy(internal); internal.* = Self.init(allocator, pool); errdefer internal.deinit(); return .{ .internal = internal, .entries = undefined, .entries_len = 0, }; } pub fn deinit(self: *CApi, allocator: std.mem.Allocator) void { self.deinitEntries(); self.internal.deinit(); allocator.destroy(self.internal); self.internal = undefined; } 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; if (self.internal.scan_result) |*old_map| { old_map.deinit(); } } 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; capi.internal.pool.spawn(loadInternal, .{capi}) catch { capi.internal.capi_lock.lock(); defer capi.internal.capi_lock.unlock(); defer capi.internal.on_change_callbacks.runAll(.{}); capi.state = .err_thread_spawn; return; }; } 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, const map = 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.scan_result = map; self.internal.on_change_callbacks.runAll(.{}); } /// Caller owns returned slice. fn scan(allocator: std.mem.Allocator) discovery.Error(Entry.CApi)!struct { []*Entry.CApi, std.StringHashMap(Entry.CApi) } { var servers = try discovery.scan(Entry.CApi, allocator, .{}); errdefer 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, servers }; } pub fn lock(capi_ptr: ?*CApi) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.capi_lock.lock(); } pub fn unlock(capi_ptr: ?*CApi) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.capi_lock.unlock(); } 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 |err| { std.log.err("Failed to register callback: {s}", .{@errorName(err)}); }; } pub fn onChangeDisarm(capi_ptr: ?*CApi, cb: OnChange.Fn, userdata: callback.UserData) callconv(.C) void { const capi = capi_ptr orelse return; capi.internal.on_change_callbacks.remove(OnChange.init(cb, userdata)); } };
-
-
-
@@ -1,130 +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 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); self.internal = undefined; } pub const FromSoodResponseError = std.mem.Allocator.Error; /// This copies slice fields of `response` so `Entry.CApi` can outlive the `response`. /// SOOD discovery response is merely a view for received UDP packets: receiving an /// another UDP packet or closing/exiting UDP code invalidates underlying buffer, thus /// makes a slice field a dangling pointer. 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(); } };
-
-
old_core/src/App/State.zig (deleted)
-
@@ -1,41 +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"); connection: ?Connection = null, const Connection = struct { server_id: []const u8, token: []const u8, }; pub fn clone(self: *const @This(), allocator: std.mem.Allocator) @This() { return .{ .connection = if (self.connection) |conn| conn: { const server_id = allocator.dupe(u8, conn.server_id); errdefer allocator.free(server_id); const token = allocator.dupe(u8, conn.token); errdefer allocator.free(token); break :conn .{ .server_id = server_id, .token = token, }; } else null, }; }
-
-
old_core/src/App/callback.zig (deleted)
-
@@ -1,71 +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"); 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: *const 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, cb: Self) void { for (self.cbs.items.len..0) |i| { if (self.cbs.items[i].callback == cb.callback and self.cbs.items[i].userdata == cb.userdata) { _ = 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); } } } }; }; }
-
-
old_core/src/lib.h (deleted)
-
@@ -1,335 +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 * * === * * 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. */ #ifndef PLAC_CORE_H #define PLAC_CORE_H #include <stddef.h> #include <sys/socket.h> /* 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; size_t const id_len; char const * const name; size_t const name_len; plac_app_server_zone_playback_state const playback_state; } plac_app_server_zone; typedef void (*plac_cb_zone_change)(void*); void plac_app_server_zone_on_change(plac_app_server_zone*, plac_cb_zone_change, void*); void plac_app_server_zone_on_change_disarm(plac_app_server_zone*, plac_cb_zone_change, void*); typedef void (*plac_cb_zone_remove)(void*); void plac_app_server_zone_on_remove(plac_app_server_zone*, plac_cb_zone_remove, void*); void plac_app_server_zone_on_remove_disarm(plac_app_server_zone*, plac_cb_zone_remove, 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; size_t const id_len; char const * const name; size_t const name_len; char const * const version; size_t const version_len; /** * An array of zones. */ plac_app_server_zone **zones; size_t const zones_len; plac_app_server_zone_list_loading const zones_loading; char const * const token; size_t const token_len; } plac_app_server; void plac_app_server_load_zones(plac_app_server*); typedef void (*plac_cb_zone_add)(plac_app_server_zone*, void*); void plac_app_server_on_zone_add(plac_app_server*, plac_cb_zone_add, void*); void plac_app_server_on_zone_add_disarm(plac_app_server*, plac_cb_zone_add, void*); typedef void (*plac_cb_zone_list_loading_change)(void*); void plac_app_server_on_zone_list_loading_change(plac_app_server*, plac_cb_zone_list_loading_change, void*); void plac_app_server_on_zone_list_loading_change_disarm(plac_app_server*, plac_cb_zone_list_loading_change, void*); /* App.ServerSelector.Entry */ typedef struct { void* __private; struct sockaddr sockaddr; socklen_t const addrlen; char const * const id; size_t const id_len; char const * const name; size_t const name_len; const char * const version; size_t 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; size_t 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*); /** * Locks server selector's mutex. * Do not forget to unlock after using. * Locking more than once from the same thread without unlocking is undefined behavior. */ void plac_app_server_selector_lock(plac_app_server_selector*); /** * Unlocks server selector's mutex. */ void plac_app_server_selector_unlock(plac_app_server_selector*); /** * Appends a callback that will be invoked everytime "plac_app_server_selector" changes. */ typedef void (*plac_cb_server_selector_change)(void*); void plac_app_server_selector_on_change(plac_app_server_selector*, plac_cb_server_selector_change, void*); void plac_app_server_selector_on_change_disarm(plac_app_server_selector*, plac_cb_server_selector_change, 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 *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 *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*); /** * Sets a filepath for application state file. Path must be an absolute path. * If this function is not called or called but arguments are invalid (e.g. parent directory * does not exist,) plac_app does neither write to nor read from a file. * * As plac_app copies pointer destination, pointer `path` does not need to be stable. */ void plac_app_set_state_path(plac_app*, char *path, size_t path_len); /** * Reconnect and restore previous state if any. */ void plac_app_restore_state(plac_app*); /** * Connects to a server. */ void plac_app_connect(plac_app*, char *server_id, size_t server_id_len, char *saved_token, size_t saved_token_len); /** * Locks app's mutex. * Do not forget to unlock after using. * Locking more than once from the same thread without unlocking is undefined behavior. */ void plac_app_lock(plac_app*); /** * Unlocks app's mutex. */ void plac_app_unlock(plac_app*); /** * Event fired when "server" field changes (connect/disconnect). */ typedef void (*plac_cb_server_change)(void*); void plac_app_on_server_change(plac_app*, plac_cb_server_change, void*); void plac_app_on_server_change_disarm(plac_app*, plac_cb_server_change, void*); /** * Event fired when "connection" field changes. */ typedef void (*plac_cb_connection_change)(void*); void plac_app_on_connection_change(plac_app*, plac_cb_connection_change, void*); void plac_app_on_connection_change_disarm(plac_app*, plac_cb_connection_change, void*); /** * Event fired when `plac_app_restore_state` finished its job, regardless of state * was restored or not. */ typedef void (*plac_cb_restore_complete)(void*); void plac_app_on_restore_complete(plac_app*, plac_cb_restore_complete, void*); void plac_app_on_restore_complete_disarm(plac_app*, plac_cb_restore_complete, void*); #endif
-
-
old_core/src/lib.modulemap (deleted)
-
@@ -1,19 +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 module PlacCore { umbrella header "plac_core.h" export * }
-
-
old_core/src/lib.vapi (deleted)
-
@@ -1,234 +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 */ [CCode (cheader_filename = "plac_core.h")] namespace PlacCore { [CCode ( cname = "plac_app_server_zone_playback_state", cprefix = "PLAC_APP_PLAYBACK_", has_type_id = false )] public enum ZonePlaybackState { STOPPED, PAUSED, PLAYING, } [CCode (cname = "plac_app_server_zone", free_function = "")] [Compact] public class Zone { [CCode (cname = "plac_cb_zone_change", has_target = true)] public delegate void OnChange (); [CCode (cname = "plac_cb_zone_remove", has_target = true)] public delegate void OnRemove (); public string id; public string name; public ZonePlaybackState playback_state; [CCode (cname = "plac_app_server_zone_on_change")] public void on_change (OnChange f); [CCode (cname = "plac_app_server_zone_on_change_disarm")] public void on_change_disarm (OnChange f); [CCode (cname = "plac_app_server_zone_on_remove")] public void on_remove (OnRemove f); [CCode (cname = "plac_app_server_zone_on_remove_disarm")] public void on_remove_disarm (OnRemove f); } [CCode ( cname = "plac_app_server_zone_list_loading", cprefix = "PLAC_APP_SERVER_ZONE_LIST_LOADING_", has_type_id = false )] public enum ZoneListLoading { NOT_LOADED, LOADING, LOADED, REFRESHING, ERR_UNEXPECTED, ERR_THREAD_SPAWN, ERR_OUT_OF_MEMORY, ERR_NON_SUCCESS, } [CCode (cname = "plac_app_server", free_function = "")] [Compact] public class Server { [CCode (cname = "plac_cb_zone_add", has_target = true)] public delegate void OnZoneAdd (Zone added); [CCode (cname = "plac_cb_zone_list_loading_change", has_target = true)] public delegate void OnZoneListLoadingChange (); public string id; public string name; public string version; [CCode (array_length_cname = "zones_len", array_length_type = "size_t")] public Zone[] zones; public ZoneListLoading zones_loading; public string token; [CCode (cname = "plac_app_server_load_zones")] public void load_zones (); [CCode (cname = "plac_app_server_on_zone_add")] public void on_zone_add (OnZoneAdd f); [CCode (cname = "plac_app_server_on_zone_add_disarm")] public void on_zone_add_disarm (OnZoneAdd f); [CCode (cname = "plac_app_server_on_zone_list_loading_change")] public void on_zone_list_loading_change (OnZoneListLoadingChange f); [CCode (cname = "plac_app_server_on_zone_list_loading_change_disarm")] public void on_zone_list_loading_change_disarm (OnZoneListLoadingChange f); } [CCode (cname = "plac_app_server_selector_entry", free_function = "")] [Compact] public class ServerSelectorEntry { public string id; public string name; public string version; } [CCode ( cname = "plac_app_server_selector_state", cprefix = "PLAC_APP_SERVER_SELECTOR_", has_type_id = false )] public enum ServerSelectorState { NOT_LOADED, LOADING, LOADED, REFRESHING, ERR_UNEXPECTED, ERR_NETWORK_UNAVAILABLE, ERR_SOCKET_PERMISSION, ERR_OUT_OF_MEMORY, ERR_SOCKET, ERR_THREAD_SPAWN, } [CCode (cname = "plac_app_server_selector", free_function = "")] [Compact] public class ServerSelector { [CCode (cname = "plac_cb_server_selector_change", has_target = true)] public delegate void OnChange (); public ServerSelectorState state; [CCode (array_length_cname = "entries_len", array_length_type = "size_t")] public ServerSelectorEntry[] entries; [CCode (cname = "plac_app_server_selector_reset")] public void reset(); [CCode (cname = "plac_app_server_selector_load")] public void load(); [CCode (cname = "plac_app_server_selector_lock")] public void lock (); [CCode (cname = "plac_app_server_selector_unlock")] public void unlock (); [CCode (cname = "plac_app_server_selector_on_change")] public void on_change (OnChange f); [CCode (cname = "plac_app_server_selector_on_change_disarm")] public void on_change_disarm (OnChange f); } [CCode ( cname = "plac_app_connection_state", cprefix = "PLAC_APP_CONNECTION_", has_type_id = false )] public enum ConnectionState { IDLE, BUSY, ERR_UNEXPECTED, ERR_NETWORK_UNAVAILABLE, ERR_SOCKET_PERMISSION, ERR_OUT_OF_MEMORY, ERR_SOCKET, ERR_REGISTRY_DOWN, ERR_FAILED_TO_REGISTER, ERR_THREAD_SPAWN, ERR_WEBSOCKET, ERR_NOT_FOUND, } [CCode (cname = "plac_app", free_function = "plac_app_destroy")] [Compact] public class App { [CCode (cname = "plac_cb_server_change", has_target = true)] public delegate void OnServerChange (); [CCode (cname = "plac_cb_connection_change", has_target = true)] public delegate void OnConnectionChange (); [CCode (cname = "plac_cb_restore_complete", has_target = true)] public delegate void OnRestoreComplete (); [CCode (cname = "plac_app_new")] public App (); public ServerSelector server_selector; public ConnectionState connection; public Server? server; [CCode (cname = "plac_app_set_state_path")] public void set_state_path(string path, uint path_len); [CCode (cname = "plac_app_restore_state")] public void restore_state(); [CCode (cname = "plac_app_connect")] public void connect(string* server_id, uint server_id_len, string? saved_token, uint saved_token_len); [CCode (cname = "plac_app_lock")] public void lock (); [CCode (cname = "plac_app_unlock")] public void unlock (); [CCode (cname = "plac_app_on_server_change")] public void on_server_change (OnServerChange f); [CCode (cname = "plac_app_on_server_change_disarm")] public void on_server_change_disarm (OnServerChange f); [CCode (cname = "plac_app_on_connection_change")] public void on_connection_change (OnConnectionChange f); [CCode (cname = "plac_app_on_connection_change_disarm")] public void on_connection_change_disarm (OnConnectionChange f); [CCode (cname = "plac_app_on_restore_complete")] public void on_restore_complete (OnRestoreComplete f); [CCode (cname = "plac_app_on_restore_complete_disarm")] public void on_restore_complete_disarm (OnRestoreComplete f); } }
-
-
old_core/src/lib.zig (deleted)
-
@@ -1,72 +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 //! This is an entrypoint of the static library. 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.lock, .{ .name = "plac_app_server_selector_lock" }); @export(&App.ServerSelector.CApi.unlock, .{ .name = "plac_app_server_selector_unlock" }); @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.setStatePath, .{ .name = "plac_app_set_state_path" }); @export(&App.CApi.restoreState, .{ .name = "plac_app_restore_state" }); @export(&App.CApi.connect, .{ .name = "plac_app_connect" }); @export(&App.CApi.lock, .{ .name = "plac_app_lock" }); @export(&App.CApi.unlock, .{ .name = "plac_app_unlock" }); @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" }); @export(&App.CApi.onRestoreComplete, .{ .name = "plac_app_on_restore_complete" }); @export(&App.CApi.onRestoreCompleteDisarm, .{ .name = "plac_app_on_restore_complete_disarm" }); }
-
-
old_core/src/roon/connection.zig (deleted)
-
@@ -1,500 +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 moo = @import("moo"); const websocket = @import("websocket"); const Response = struct { wrote: *std.Thread.ResetEvent, /// `null` on OOM. data: ?[]const u8, /// If this field is `true`, `data` property does not have meaningful data. canceled: bool = false, }; 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, // Making this non-pointer causes invalid read/write when thread terminates. ws: *websocket.Client, addr: []const u8, rng: std.Random.Xoshiro256, pool: *std.Thread.Pool, responses: ResponsesStore, responses_mutex: *std.Thread.Mutex, subscription_id_cnt: u64 = 0, state: State = .connecting, state_mutex: std.Thread.Mutex = .{}, const State = enum { connecting, connected, closed, }; pub const InitError = error{ WebSocketClientCreationError, WebSocketHandshakeError, PRNGSeedGenerationFailure, } || std.mem.Allocator.Error; pub fn init(child_allocator: std.mem.Allocator, address: std.net.Address, pool: *std.Thread.Pool) 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 = try allocator.create(websocket.Client); client.* = websocket.Client.init(allocator, .{ .port = address.getPort(), .host = addr_string[0..port_start], }) catch return InitError.WebSocketClientCreationError; errdefer client.deinit(); std.log.debug("Performing WebSocket handshake...", .{}); 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; }), .pool = pool, .responses = responses, .responses_mutex = responses_mutex, .thread_safe_allocator = tsa, }; } pub fn listen(self: *Connection, on_request: RequestHandler) std.Thread.SpawnError!void { std.log.debug("Spawning WebSocket request handler thread...", .{}); self.pool.spawn(readLoop, .{ self, on_request }) catch |err| { // From user's perspective, normal spawn error and pool's error is no // different. std.log.warn( "Spawning a thread from pool failed, simplifying error ({s})", .{@errorName(err)}, ); return std.Thread.SpawnError.Unexpected; }; } pub fn deinit(self: *Connection) void { const state = state: { self.state_mutex.lock(); defer self.state_mutex.unlock(); break :state self.state; }; if (state != .closed) { self.close(); } 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| { std.log.debug("Releasing response handler for request ID={}...", .{entry.key_ptr.*}); if (entry.value_ptr.*.data) |data| { self.allocator.free(data); } } 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); } /// Closes connection and cancels active message listeners. fn close(self: *Connection) void { // Intentionally blocks `deinit` during closing procedure, so it'll run after closed. self.state_mutex.lock(); defer self.state_mutex.unlock(); std.log.debug("Closing WebSocket connection...", .{}); // 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)}); }; { self.responses_mutex.lock(); defer self.responses_mutex.unlock(); var iter = self.responses.iterator(); while (iter.next()) |entry| { entry.value_ptr.*.canceled = true; entry.value_ptr.*.wrote.set(); } } self.state = .closed; } pub fn newRequestId(self: *Connection) i64 { return self.rng.random().int(i64); } pub const RequestError = error{ Canceled, }; 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(); if (response.canceled) { return RequestError.Canceled; } return response.data orelse return std.mem.Allocator.Error.OutOfMemory; } fn newSubscriptionId(self: *@This()) u64 { const id = self.subscription_id_cnt; self.subscription_id_cnt += 1; return id; } /// Returns an interface object for subscription management. /// /// `service_id` is an ID of the service. /// `subject` is an entity name to subscribe. /// /// The resulted service name would be `<service_id>/subscribe_<subject>` and /// `<service_id>/unsubscribe_<subject>`. /// /// Both `service_id` and `subject` must be pointer stable during lifetime of the /// returned struct. /// /// Caller have to call `.activate()` method on the returned struct to receive updates. /// To capture received updates, use `.next()` method on the returned struct. pub fn subscribe(self: *@This(), service_id: []const u8, subject: []const u8) Subscription { return Subscription{ .request_id = self.newRequestId(), .subscription_id = self.newSubscriptionId(), .conn = self, .response = Response{ .wrote = undefined, .data = null, }, .response_wrote = std.Thread.ResetEvent{}, .service_id = service_id, .subject = subject, }; } pub const Subscription = struct { request_id: i64, subscription_id: u64, conn: *Connection, response: Response, response_wrote: std.Thread.ResetEvent, service_id: []const u8, subject: []const u8, request_message: []u8 = undefined, /// Sets up a response listener. pub fn activate(self: *@This(), allocator: std.mem.Allocator) !void { self.response.wrote = &self.response_wrote; { self.conn.responses_mutex.lock(); defer self.conn.responses_mutex.unlock(); try self.conn.responses.put(self.request_id, &self.response); } const service_tmpl = "{s}/subscribe_{s}"; const service_len = std.fmt.count(service_tmpl, .{ self.service_id, self.subject }); const service = try allocator.alloc(u8, service_len); defer allocator.free(service); var service_fbs = std.io.fixedBufferStream(service); try std.fmt.format(service_fbs.writer(), service_tmpl, .{ self.service_id, self.subject }); const meta = moo.Metadata{ .service = service_fbs.getWritten(), .verb = "REQUEST", }; const body_tmpl = "{{\"subscription_key\":{}}}"; const body_len = comptime std.fmt.count(body_tmpl, .{std.math.maxInt(u64)}); var body_bytes: [body_len]u8 = undefined; var body_bytes_fbs = std.io.fixedBufferStream(&body_bytes); try std.fmt.format(body_bytes_fbs.writer(), body_tmpl, .{self.subscription_id}); const body = moo.RawBody{ .bytes = body_bytes_fbs.getWritten(), }; var header = body.getHeader(self.request_id); header.content_type = "application/json"; const req_buffer = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); defer allocator.free(req_buffer); var req_fbs = std.io.fixedBufferStream(req_buffer); try moo.encode(req_fbs.writer(), meta, header, body); try self.conn.ws.writeBin(req_fbs.getWritten()); } /// Release resources and unregister response handler. /// This does not sends unsubscribe command to the server. /// Call to this function after a call to `Connection.deinit()` is useless and invalid: /// `Connection.deinit()` releases this struct's resource as well. pub fn deinit(self: *@This()) void { self.conn.responses_mutex.lock(); defer self.conn.responses_mutex.unlock(); _ = self.conn.responses.remove(self.request_id); } /// Sends unsubscribe request to the server. pub fn unsubscribe(self: *@This(), allocator: std.mem.Allocator) !void { const request_id = self.conn.newRequestId(); const service_tmpl = "{s}/unsubscribe_{s}"; const service_len = std.fmt.count(service_tmpl, .{ self.service_id, self.subject }); const service = try allocator.alloc(u8, service_len); defer allocator.free(service); var service_fbs = std.io.fixedBufferStream(service); try std.fmt.format(service_fbs.writer(), service_tmpl, .{ self.service_id, self.subject }); const meta = moo.Metadata{ .service = service_fbs.getWritten(), .verb = "REQUEST", }; const body_tmpl = "{{\"subscription_key\":{}}}"; const body_len = comptime std.fmt.count(body_tmpl, .{std.math.maxInt(u64)}); var body_bytes: [body_len]u8 = undefined; var body_bytes_fbs = std.io.fixedBufferStream(&body_bytes); try std.fmt.format(body_bytes_fbs.writer(), body_tmpl, .{self.subscription_id}); const body = moo.RawBody{ .bytes = body_bytes_fbs.getWritten(), }; var header = body.getHeader(request_id); header.content_type = "application/json"; const req_buffer = try allocator.alloc( u8, meta.getEncodeSize() + header.getEncodeSize() + body.getEncodeSize(), ); defer allocator.free(req_buffer); var req_fbs = std.io.fixedBufferStream(req_buffer); try moo.encode(req_fbs.writer(), meta, header, body); try self.conn.ws.writeBin(req_fbs.getWritten()); } /// Returns incoming updates to the subject. If the subscription is unsubscribed, /// this function returns null. /// /// Calling this function before `.activate()` blocks the thread indefinetely. /// /// Caller have to release the returned memory using `Connection.allocator`. pub fn next(self: *@This(), allocator: std.mem.Allocator) std.mem.Allocator.Error!?[]const u8 { var entry = self.conn.responses.get(self.request_id) orelse { return null; }; entry.wrote.wait(); if (entry.canceled) { return null; } const data = entry.data orelse { return std.mem.Allocator.Error.OutOfMemory; }; defer self.conn.allocator.free(data); { self.conn.responses_mutex.lock(); defer self.conn.responses_mutex.unlock(); entry.canceled = false; entry.data = null; entry.wrote.reset(); } return try allocator.dupe(u8, data); } }; }; fn readLoop(conn: *Connection, on_request: RequestHandler) void { { conn.state_mutex.lock(); defer conn.state_mutex.unlock(); conn.state = .connected; } while (true) { const msg = (conn.ws.read() catch return) orelse unreachable; 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.log.warn("Failed to parse MOO metadata: {s}", .{@errorName(err)}); continue; }; // We just want to know Request-Id header. const header, _ = moo.NoBodyHeaders.parse(msg.data, header_ctx) catch |err| { std.log.warn("Failed to parse MOO headers: {s}", .{@errorName(err)}); continue; }; { const response = response: { conn.responses_mutex.lock(); defer conn.responses_mutex.unlock(); break :response conn.responses.get(header.request_id); }; if (response) |store| { if (store.wrote.isSet()) { std.log.warn( "Received more than one message having same Request-Id({d})", .{header.request_id}, ); continue; } defer store.wrote.set(); const bytes = conn.allocator.dupe(u8, msg.data) catch |err| { std.log.err("Unable to release incoming WS message: {s}", .{@errorName(err)}); 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.log.warn("Service server handler returned an error: {s}", .{@errorName(err)}); continue; }; if (!wrote) { std.log.info("Unhandled incoming request: service={s}\n", .{meta.service}); continue; } 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)}); continue; }; }, .ping => conn.ws.writePong(msg.data) catch {}, .pong => {}, .close => { // TODO: Release Connection or notify to upstream conn.ws.close(.{}) catch return; return; }, } } }
-
-
old_core/src/roon/discovery.zig (deleted)
-
@@ -1,280 +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 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.getPtr(response.unique_id); defer if (stale) |server| { server.deinit(); }; var 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, }; }; }
-
-
old_core/src/roon/extension.zig (deleted)
-
@@ -1,80 +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 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(); } }; }
-
-
old_core/src/roon/service.zig (deleted)
-
@@ -1,45 +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 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; } }; }
-
-
old_core/src/roon/services/ping.zig (deleted)
-
@@ -1,39 +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 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); } });
-
-
old_core/src/roon/services/registry.zig (deleted)
-
@@ -1,102 +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 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); defer 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, .allocate = .alloc_always, }); } 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); defer 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.log.err("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, .allocate = .alloc_always, }); } };
-
-
old_core/src/roon/services/transport.zig (deleted)
-
@@ -1,221 +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 moo = @import("moo"); const Connection = @import("../connection.zig").Connection; /// Use of this service requires registration. pub const TransportService = struct { pub const id = "com.roonlabs.transport:2"; pub const PlaybackState = enum { playing, paused, loading, stopped, pub fn jsonParseFromValue( _: std.mem.Allocator, value: std.json.Value, _: std.json.ParseOptions, ) std.json.ParseFromValueError!@This() { return switch (value) { .string => |v| { inline for (@typeInfo(@This()).@"enum".fields) |field| { if (std.mem.eql(u8, field.name, v)) { return @enumFromInt(field.value); } } return std.json.ParseFromValueError.InvalidEnumTag; }, else => std.json.ParseFromValueError.UnexpectedToken, }; } }; pub const Zone = struct { zone_id: []const u8, display_name: []const u8, state: PlaybackState, }; pub const GetZonesResponse = struct { zones: []Zone, }; pub const GetZonesError = error{ NonSuccessResponse, }; pub fn getZones(allocator: std.mem.Allocator, conn: *Connection) !moo.JsonBody(GetZonesResponse) { const request_id = conn.newRequestId(); const meta = moo.Metadata{ .service = id ++ "/get_zones", .verb = "REQUEST", }; const body = moo.NoBody{}; const headers = body.getHeader(request_id); const request = try allocator.alloc(u8, meta.getEncodeSize() + headers.getEncodeSize() + body.getEncodeSize()); defer allocator.free(request); var fbs = std.io.fixedBufferStream(request); try moo.encode(fbs.writer(), meta, headers, body); const message = try conn.request(request_id, request); defer conn.allocator.free(message); const meta_res, const header_ctx = try moo.Metadata.parse(message); if (!std.mem.eql(u8, meta_res.service, "Success")) { std.log.warn("Expected \"Success\" for {s}/get_zones endpoint, got \"{s}\"\n", .{ id, meta_res.service }); return GetZonesError.NonSuccessResponse; } const headers_res, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); std.log.debug("Got successful response on {s}/get_zones (request_id={d})", .{ id, headers_res.request_id }); return try moo.JsonBody(GetZonesResponse).parse(allocator, message, body_ctx, .{ .ignore_unknown_fields = true, .allocate = .alloc_always, }); } const SubscribeZoneChangesSubscribedResponse = struct { zones: []const Zone = &.{}, }; const SubscribeZoneChangesChangedResponse = struct { zones_removed: []const []const u8 = &.{}, zones_added: []const Zone = &.{}, zones_changed: []const Zone = &.{}, }; pub const ZoneChangeSubscription = struct { subscription: Connection.Subscription, const Data = struct { allocator: std.mem.Allocator, added_zones: []const Zone, changed_zones: []const Zone, removed_ids: []const []const u8, original_message: union(enum) { subscribed: moo.JsonBody(SubscribeZoneChangesSubscribedResponse), changed: moo.JsonBody(SubscribeZoneChangesChangedResponse), }, message_bytes: []const u8, pub fn deinit(self: *const Data) void { switch (self.original_message) { .subscribed => |msg| msg.deinit(), .changed => |msg| msg.deinit(), } self.allocator.free(self.message_bytes); } }; const Iterator = struct { parent: *ZoneChangeSubscription, pub const Error = error{ UnknownService, UnsubscribedByServer, }; /// Returns the next zone change event. This function blocks until next event. /// Caller must call `.deinit()` after using the returned struct. pub fn next(self: *Iterator, allocator: std.mem.Allocator) !?Data { const message = try self.parent.subscription.next(allocator) orelse return null; errdefer allocator.free(message); const meta, const header_ctx = try moo.Metadata.parse(message); if (std.mem.eql(u8, meta.service, "Unsubscribed")) { return Error.UnsubscribedByServer; } if (std.mem.eql(u8, meta.service, "Subscribed")) { _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); const body = try moo.JsonBody(SubscribeZoneChangesSubscribedResponse).parse( allocator, message, body_ctx, .{ .ignore_unknown_fields = true, }, ); return Data{ .allocator = allocator, .added_zones = body.value.zones, .changed_zones = &.{}, .removed_ids = &.{}, .original_message = .{ .subscribed = body, }, .message_bytes = message, }; } if (std.mem.eql(u8, meta.service, "Changed")) { _, const body_ctx = try moo.WellKnownHeaders.parse(message, header_ctx); const body = try moo.JsonBody(SubscribeZoneChangesChangedResponse).parse( allocator, message, body_ctx, .{ .ignore_unknown_fields = true, }, ); return Data{ .allocator = allocator, .added_zones = body.value.zones_added, .changed_zones = body.value.zones_changed, .removed_ids = body.value.zones_removed, .original_message = .{ .changed = body, }, .message_bytes = message, }; } return Error.UnknownService; } }; pub fn init(conn: *Connection) ZoneChangeSubscription { return .{ .subscription = conn.subscribe(id, "zones"), }; } /// Call to this function after the call to `Connection.deinit()` is useless and invalid. pub fn deinit(self: *ZoneChangeSubscription) void { self.subscription.deinit(); } pub fn iter(self: *ZoneChangeSubscription, allocator: std.mem.Allocator) !Iterator { try self.subscription.activate(allocator); return .{ .parent = self }; } }; };
-