Changes
8 changed files (+448/-208)
-
cli/src/app.zig (new)
-
@@ -0,0 +1,143 @@// 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 core = @import("core"); const State = @import("./app/State.zig"); pub const Inputs = struct { core_id: ?[]const u8 = null, file_path: ?[]const u8, ephemeral: bool = false, pub const ParseError = error{ MissingCoreId, }; pub fn parse(self: Inputs) ParseError!ParsedInputs { const core_id = self.core_id orelse return error.MissingCoreId; return .{ .core_id = core_id, .file_path = self.file_path orelse State.default_path, .ephemeral = self.ephemeral, }; } }; const ParsedInputs = struct { core_id: []const u8, file_path: []const u8, ephemeral: bool, }; pub const App = struct { allocator: std.mem.Allocator, state: *State, app: *core.Application, pub const InitError = error{ ServerFindError, ServerNotFound, UnableToConnect, }; pub fn init(allocator: std.mem.Allocator, inputs: ParsedInputs) !@This() { var state = try allocator.create(State); state.* = State.init(allocator, .{}) catch |err| { std.log.err("Failed to prepare application state: {s}", .{@errorName(err)}); return err; }; errdefer state.deinit(); const scanner = core.server.ServerScanner.make() orelse { std.log.err("Unable to create a scanner: out of memory", .{}); return std.mem.Allocator.Error.OutOfMemory; }; defer scanner.free(); const core_id_cstr = allocator.dupeZ(u8, inputs.core_id) catch |err| { std.log.err("Failed to create connection: OOM", .{}); return err; }; defer allocator.free(core_id_cstr); var result = scanner.find(core_id_cstr.ptr, core_id_cstr.len) orelse { std.log.err("Unable to find server: out of memory", .{}); return std.mem.Allocator.Error.OutOfMemory; }; defer result.free(); if (result.code != .ok) { std.log.err("Failed to find server: {s}", .{@tagName(result.code)}); return InitError.ServerFindError; } const server = result.server orelse { std.log.err("No Roon Server found for id={s}", .{inputs.core_id}); return InitError.ServerNotFound; }; const app_ptr = core.Application.makeFromServer(server); const app = app_ptr orelse { std.log.err("Failed to initialize Application: OOM", .{}); return std.mem.Allocator.Error.OutOfMemory; }; var token: ?[:0]const u8 = null; for (state.registrations.items) |r| { if (std.mem.eql(u8, r.core_id, inputs.core_id)) { token = allocator.dupeZ(u8, r.token) catch |err| { std.log.err("Failed to copy token: {s}", .{@errorName(err)}); return err; }; break; } } defer if (token) |tok| allocator.free(tok); const new_token = app.connect(if (token) |tok| tok.ptr else null) orelse { std.log.err("Unable to connect", .{}); return InitError.UnableToConnect; }; state.putToken(inputs.core_id, std.mem.span(new_token)) catch |err| { std.log.err("Failed to save new token: {s}", .{@errorName(err)}); return err; }; state.save() catch |err| { std.log.err("Failed to save state to disk: {s}", .{@errorName(err)}); return err; }; std.log.debug("Extension enabled (token={s})", .{new_token}); return .{ .allocator = allocator, .state = state, .app = app, }; } pub fn deinit(self: *@This()) void { self.state.deinit(); self.allocator.destroy(self.state); self.app.free(); } };
-
-
cli/src/app/State.zig (new)
-
@@ -0,0 +1,119 @@// 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 ExtensionRegistration = struct { core_id: []const u8, token: []const u8, }; const FileContent = struct { registrations: []ExtensionRegistration, }; registrations: std.ArrayList(ExtensionRegistration), allocator: std.mem.Allocator, file_path: ?[]const u8, /// Unless user provided custom file path, connection state will be saved to /// and loaded from this file. pub const default_path = ".roon.json"; /// Creates a new empty state. Save operation on the returned state is no-op. pub fn initEphemeral(allocator: std.mem.Allocator) @This() { return .{ .registrations = std.ArrayList(ExtensionRegistration).init(allocator), .allocator = allocator, .file_path = null, }; } pub const InitOptions = struct { file_path: []const u8 = default_path, }; pub fn init(allocator: std.mem.Allocator, opts: InitOptions) !@This() { const file = std.fs.cwd().openFile(opts.file_path, .{}) catch |err| switch (err) { error.FileNotFound => { return .{ .registrations = std.ArrayList(ExtensionRegistration).init(allocator), .allocator = allocator, .file_path = opts.file_path, }; }, else => return err, }; defer file.close(); const contents = try file.readToEndAlloc(allocator, std.math.maxInt(usize)); defer allocator.free(contents); const loaded = try std.json.parseFromSlice(FileContent, allocator, contents, .{}); defer loaded.deinit(); var registrations = try allocator.alloc(ExtensionRegistration, loaded.value.registrations.len); for (loaded.value.registrations, 0..) |r, i| { registrations[i] = .{ .core_id = try allocator.dupe(u8, r.core_id), .token = try allocator.dupe(u8, r.token), }; } return @This(){ .registrations = std.ArrayList(ExtensionRegistration).fromOwnedSlice(allocator, registrations), .allocator = allocator, .file_path = opts.file_path, }; } /// Update or add token. Updated entry copies input buffer: caller can release input buffers /// then access struct fields. pub fn putToken(self: *@This(), core_id: []const u8, token: []const u8) std.mem.Allocator.Error!void { for (self.registrations.items) |*r| { if (std.mem.eql(u8, r.core_id, core_id)) { self.allocator.free(r.token); r.token = try self.allocator.dupe(u8, token); return; } } try self.registrations.append(.{ .core_id = try self.allocator.dupe(u8, core_id), .token = try self.allocator.dupe(u8, token), }); } pub fn save(self: *const @This()) !void { const file_path = self.file_path orelse return; const file = try std.fs.cwd().createFile(file_path, .{}); defer file.close(); try std.json.stringify( FileContent{ .registrations = self.registrations.items }, .{ .whitespace = .indent_tab }, file.writer(), ); } pub fn deinit(self: *@This()) void { for (self.registrations.items) |r| { self.allocator.free(r.core_id); self.allocator.free(r.token); } self.registrations.deinit(); }
-
-
-
@@ -0,0 +1,75 @@// 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 App = @import("../app.zig").App; 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, plac: *const App) 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.ok; } const conn = plac.app.conn orelse { std.log.err("Connection lost.", .{}); return ExitCode.not_ok; }; const zones = core.services.Transport.getZones(allocator, conn) catch |err| { std.log.err("Unable to get zones: {s}", .{@errorName(err)}); return ExitCode.not_ok; }; defer zones.deinit(); const name = res.args.zone orelse { std.log.err("--zone is required.", .{}); return ExitCode.incorrect_usage; }; for (zones.value.zones) |zone| { if (std.mem.eql(u8, name, zone.display_name)) { std.fmt.format(std.io.getStdOut().writer(), "{s}\n", .{@tagName(zone.state)}) catch {}; return ExitCode.ok; } } std.log.err("No zone found named {s}", .{name}); return ExitCode.not_ok; }
-
-
-
@@ -18,34 +18,17 @@ const std = @import("std");const clap = @import("clap"); const core = @import("core"); const App = @import("../app.zig").App; const ExitCode = @import("../exit.zig").ExitCode; const find = @import("./server/find.zig"); const list = @import("./server/list.zig"); const parser = .{ .id = clap.parsers.string, .path = clap.parsers.string, }; const params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\-c, --core-id <id> Roon Server's ID. \\-s, --state <path> Path to state file to load from and save to. \\ ); const ExtensionRegistration = struct { core_id: []const u8, token: []const u8, }; const RoonJson = struct { registrations: []ExtensionRegistration = &.{}, }; pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator) ExitCode { pub fn run(allocator: std.mem.Allocator, iter: *std.process.ArgIterator, _: *const App) ExitCode { var diag = clap.Diagnostic{}; var res = clap.parseEx(clap.Help, ¶ms, parser, iter, .{ var res = clap.parseEx(clap.Help, ¶ms, .{}, iter, .{ .diagnostic = &diag, .allocator = allocator, }) catch |err| {
-
@@ -54,173 +37,10 @@ return ExitCode.incorrect_usage;}; defer res.deinit(); const stdout = std.io.getStdOut().writer(); const stderr = std.io.getStdErr().writer(); if (res.args.help > 0) { clap.help(stdout, clap.Help, ¶ms, .{}) catch {}; clap.help(std.io.getStdOut().writer(), clap.Help, ¶ms, .{}) catch {}; return ExitCode.ok; } const state_path = res.args.state orelse ".roon.json"; const state_file = std.fs.cwd().openFile(state_path, .{ .mode = .read_write }) catch |err| new: { switch (err) { error.FileNotFound => { const new = std.fs.cwd().createFile(state_path, .{ .read = true }) catch |new_err| { stderr.print("Unable to create {s}: {s}\n", .{ state_path, @errorName(new_err) }) catch {}; return ExitCode.not_ok; }; std.json.stringify(RoonJson{}, .{}, new.writer()) catch |write_err| { stderr.print("Unable to write to {s}: {s}\n", .{ state_path, @errorName(write_err) }) catch {}; return ExitCode.not_ok; }; new.seekTo(0) catch |seek_err| { stderr.print("Unable to seek {s} to head: {s}\n", .{ state_path, @errorName(seek_err) }) catch {}; return ExitCode.not_ok; }; break :new new; }, else => { stderr.print("Failed to open {s}: {s}\n", .{ state_path, @errorName(err) }) catch {}; return ExitCode.not_ok; }, } }; defer state_file.close(); const state_contents = state_file.readToEndAlloc(allocator, std.math.maxInt(usize)) catch |err| { stderr.print("Unable to read {s}: {s}\n", .{ state_path, @errorName(err) }) catch {}; return ExitCode.not_ok; }; defer allocator.free(state_contents); state_file.seekTo(0) catch |err| { stderr.print("Unable to seek {s} to head: {s}\n", .{ state_path, @errorName(err) }) catch {}; return ExitCode.not_ok; }; const prev_state = std.json.parseFromSlice(RoonJson, allocator, state_contents, .{}) catch |err| { stderr.print("Unable to parse {s}: {s}\n", .{ state_path, @errorName(err) }) catch {}; return ExitCode.not_ok; }; defer prev_state.deinit(); const core_id = res.args.@"core-id" orelse { stderr.print("--core-id is required\n", .{}) catch {}; clap.help(stderr, clap.Help, ¶ms, .{}) catch {}; return ExitCode.incorrect_usage; }; const scanner = core.server.ServerScanner.make() orelse { // TODO: Print as error log stderr.print("Unable to create a scanner: out of memory\n", .{}) catch {}; // TODO: Create dedicated exit code. return ExitCode.not_ok; }; defer scanner.free(); const core_id_cstr = allocator.dupeZ(u8, core_id) catch { stderr.print("Failed to create connection: OOM\n", .{}) catch {}; return ExitCode.not_ok; }; defer allocator.free(core_id_cstr); var result = scanner.find(core_id_cstr.ptr, core_id_cstr.len) orelse { // TODO: Print as error log stderr.print("Unable to find server: out of memory\n", .{}) catch {}; // TODO: Create dedicated exit code. return ExitCode.not_ok; }; defer result.free(); if (result.code != .ok) { // TODO: Print as error log stderr.print("Failed to find server: {s}\n", .{@tagName(result.code)}) catch {}; return ExitCode.not_ok; } const server = result.server orelse { // TODO: Print as error log stderr.print("No Roon Server id={s} found\n", .{core_id}) catch {}; return ExitCode.not_ok; }; const app_ptr = core.Application.makeFromServer(server); const app = app_ptr orelse { stderr.print("Failed to initialize Application: OOM\n", .{}) catch {}; return ExitCode.not_ok; }; defer app.free(); var token: ?[:0]const u8 = null; for (prev_state.value.registrations) |r| { if (std.mem.eql(u8, r.core_id, core_id)) { token = allocator.dupeZ(u8, r.token) catch { stderr.print("Failed to load token: OOM\n", .{}) catch {}; return ExitCode.not_ok; }; break; } } defer if (token) |tok| allocator.free(tok); const new_token = app.connect(if (token) |tok| tok.ptr else null) orelse { // TODO: Print as error log stderr.print("Unable to connect\n", .{}) catch {}; return ExitCode.not_ok; }; var registrations = std.ArrayList(ExtensionRegistration).init(allocator); defer registrations.deinit(); for (prev_state.value.registrations) |r| { if (std.mem.eql(u8, r.core_id, core_id)) { registrations.append(ExtensionRegistration{ .core_id = r.core_id, .token = std.mem.span(new_token), }) catch { // TODO: Print as error log stderr.print("Out of memory on writing state\n", .{}) catch {}; return ExitCode.not_ok; }; } else { registrations.append(r) catch { // TODO: Print as error log stderr.print("Out of memory on writing state\n", .{}) catch {}; return ExitCode.not_ok; }; } } if (token == null) { registrations.append(ExtensionRegistration{ .core_id = core_id, .token = std.mem.span(new_token), }) catch { // TODO: Print as error log stderr.print("Out of memory on writing state\n", .{}) catch {}; return ExitCode.not_ok; }; } const new_state = RoonJson{ .registrations = registrations.toOwnedSlice() catch { // TODO: Print as error log stderr.print("Out of memory on writing state\n", .{}) catch {}; return ExitCode.not_ok; }, }; std.json.stringify(new_state, .{}, state_file.writer()) catch |err| { // TODO: Print as error log stderr.print("Unable to write to {s}: {s}\n", .{ state_path, @errorName(err) }) catch {}; return ExitCode.not_ok; }; std.debug.print("Registration complete. token: {s}\n", .{new_token}); return ExitCode.ok; }
-
-
-
@@ -19,30 +19,38 @@ const clap = @import("clap");const build_config = @import("build_config"); const core = @import("core"); const app = @import("./app.zig"); const ExitCode = @import("./exit.zig").ExitCode; const server = @import("./commands/server.zig"); const register = @import("./commands/register.zig"); const playback = @import("./commands/playback.zig"); const version = "0.0.0"; const Commands = enum { server, register, playback, version, }; const global_parser = .{ .command = clap.parsers.enumeration(Commands), .id = clap.parsers.string, .path = clap.parsers.string, }; const global_params = clap.parseParamsComptime( \\-h, --help Prints this message to stdout and exits. \\-v, --verbose Enables debug logging. \\-c, --core-id <id> Roon Server's ID. \\-s, --state <path> Path to state file to load from and save to. \\<command> \\Available commands: \\* version ... Prints version to stdout and exits. \\* server ... Lists or gets information of Roon Server on network \\* register ... Register extension to Roon Server and exists. \\* playback ... Display playback state of a zone. \\ );
-
@@ -96,6 +104,28 @@ .version => {try std.fmt.format(std.io.getStdOut().writer(), "{s}\n", .{version}); return @intFromEnum(ExitCode.ok); }, .register => return @intFromEnum(register.run(allocator, &iter)), else => |main_command| { const inputs = app.Inputs.parse(.{ .core_id = res.args.@"core-id", .file_path = res.args.state, }) catch |err| { std.log.err("Invalid usage: {s}", .{@errorName(err)}); clap.help(std.io.getStdErr().writer(), clap.Help, &global_params, .{}) catch {}; return @intFromEnum(ExitCode.incorrect_usage); }; var plac = app.App.init(allocator, inputs) catch |err| { std.log.err("Unable to initialize app: {s}", .{@errorName(err)}); return @intFromEnum(ExitCode.not_ok); }; defer plac.deinit(); return switch (main_command) { .register => @intFromEnum(register.run(allocator, &iter, &plac)), .playback => @intFromEnum(playback.run(allocator, &iter, &plac)), // Zig does not support narrowing, thus main_command is still type of `Commands`. else => @intFromEnum(ExitCode.incorrect_usage), }; }, } }
-
-
-
@@ -29,28 +29,6 @@ const TransportService = @import("./roon/services/transport.zig").TransportService;const allocator = std.heap.c_allocator; pub const PlaybackState = enum(c_int) { /// Completely lost, or loading from server. unknown = 0, /// No track is selected. stopped = 1, /// Track is selected but playback is paused. not_playing = 2, /// In transition of not_playing -> playing requesting_play = 3, /// Playing a track. playing = 4, /// In transition of playing -> not_playing requesting_pause = 5, }; pub const Zone = extern struct { id: [*:0]const u8, id_len: usize, playback_state: PlaybackState, }; const AppExtension = Extension(.{ .id = "jp.pocka.plac", .display_name = "Plac",
-
-
-
@@ -19,6 +19,10 @@pub const server = @import("./server.zig"); pub const Application = @import("./application.zig").Application; pub const services = struct { pub const Transport = @import("./roon/services/transport.zig").TransportService; }; comptime { @export(&server.Server.dupe, .{ .name = "plac_server_dupe" }); @export(&server.Server.free, .{ .name = "plac_server_free" });
-
-
-
@@ -19,9 +19,80 @@const moo = @import("moo"); const Connection = @import("../connection.zig").Connection; const Extension = @import("../extension.zig").Extension; /// Use of this service requires registration. pub const TransportService = struct { pub const id = "com.roonlabs.transport:2"; 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); errdefer 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 }); } };
-