diff --git a/build/app.zig b/build/app.zig index 84f259601..cb3d78d71 100644 --- a/build/app.zig +++ b/build/app.zig @@ -891,9 +891,6 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // dvui half (`sdk/replay/`) compiles on its own below, and is covered by // `tests/integration.zig`. .{ "fizzy-tape-tests", "sdk/tape/root.zig" }, - // The remote-view protocol (`sdk/remote/`, plans/EMBEDDED_EDITOR_PLAN.md step 2): messages, - // framing, the connection rules and a loopback pair. std-only, so a host's adapter can link it. - .{ "fizzy-remote-tests", "sdk/remote/root.zig" }, // Where Report a Bug goes when the build isn't told. std-only (see issues_url.zig); the // build is its only user. .{ "fizzy-issues-url-tests", "build/issues_url.zig" }, @@ -1037,6 +1034,34 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. })); } + { + // The remote-view protocol (`sdk/remote/`, plans/EMBEDDED_EDITOR_PLAN.md steps 2 and 5): + // messages, framing, the connection rules, and the client that finds, starts and connects + // to fizzy, against a fake fizzy executable built here. std-only, so a host's adapter can + // link it. + const fake_fizzy = b.addExecutable(.{ + .name = "fake-fizzy", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("sdk/remote/fake_fizzy.zig"), + }), + }); + const fake_options = b.addOptions(); + fake_options.addOptionPath("fake_fizzy", fake_fizzy.getEmittedBin()); + const remote_tests = b.addTest(.{ + .name = "fizzy-remote-tests", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("sdk/remote/root.zig"), + .imports = &.{.{ .name = "fake_fizzy_options", .module = fake_options.createModule() }}, + }), + .filters = test_filters, + }); + try unit_test_artifacts.append(b.allocator, remote_tests); + } + for (unit_test_artifacts.items) |unit_test| { test_step.dependOn(&b.addRunArtifact(unit_test).step); check_step.dependOn(&unit_test.step); diff --git a/sdk/remote/Client.zig b/sdk/remote/Client.zig new file mode 100644 index 000000000..1d93f469e --- /dev/null +++ b/sdk/remote/Client.zig @@ -0,0 +1,617 @@ +//! `Client` — a host application's way to a running fizzy: find the installed one, quietly start it +//! when nothing listens, connect to its control socket, upgrade to the remote-view stream and +//! exchange hellos (plans/EMBEDDED_EDITOR_PLAN.md, step 5). `capi.zig` is the C ABI over it. +//! +//! The steps, and the error each one ends in: +//! +//! 1. *Locate* the executable (`locate`): `FIZZY_EXE`, then the OS's usual install places +//! (`candidates`), then the system's own index (Launch Services, the registry's App Paths). +//! Nothing found is `NotInstalled`, or `Sandboxed` from a sandbox, which cannot see them. +//! 2. Ask it for its socket (`fizzy --control-path`, with the `--profile` in play), unless the +//! caller names the socket. +//! 3. *Dial.* If nothing listens, start fizzy with `--quiet` (macOS: `open -g -j`, which never +//! takes focus) and dial again with a growing pause until the deadline. A start that dies +//! is `StartFailed`, one that is only slow `TimedOut`. Fizzy is never started with a +//! private profile of its own to get around a sandbox: that would be a second fizzy the +//! person did not ask for. +//! 4. *Upgrade* `GET /remote` and exchange hellos. `FizzyTooOld` (no `/remote`, or a protocol +//! older than ours) and `ClientTooOld` (newer than ours) say who updates. +//! +//! `reconnect` runs the same steps again after a drop. std-only. +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const message = @import("message.zig"); +const codec = @import("codec.zig"); +const Endpoint = @import("Endpoint.zig"); + +const Client = @This(); + +pub const upgrade_token = "fizzy-remote-view/1"; + +/// The environment variable that names the executable, and the profile folder, for a host that +/// must not search (and for tests). +pub const env_exe = "FIZZY_EXE"; +pub const env_profile = "FIZZY_PROFILE"; + +pub const Error = error{ + /// No fizzy where one would be installed. + NotInstalled, + /// Nothing listens and the caller asked not to start one. + NotRunning, + /// This process cannot see fizzy's install or socket, or may not start it. + Sandboxed, + /// Fizzy has no remote view, or speaks only older versions: update fizzy. + FizzyTooOld, + /// Fizzy speaks only newer versions: update the add-on. + ClientTooOld, + /// Started (or found) but not answering by the deadline. + TimedOut, + /// The fizzy we started exited with a failure before it listened. + StartFailed, + /// The peer is not speaking the protocol. + Protocol, + /// The stream ended. + Closed, + OutOfMemory, +}; + +/// Which application to find: an app built on fizzy takes its own. +pub const Identity = struct { + /// The executable's name and the Velopack pack id. + name: []const u8 = "fizzy", + /// The macOS bundle's name (`Fizzy.app`). + display_name: []const u8 = "Fizzy", + bundle_id: []const u8 = "com.foxnne.fizzy", +}; + +/// What the process's environment says, as values, so a host that has no libc environment (or a +/// test) can say it too. +pub const Env = struct { + os: std.Target.Os.Tag = builtin.os.tag, + exe: ?[]const u8 = null, + profile: ?[]const u8 = null, + home: ?[]const u8 = null, + local_app_data: ?[]const u8 = null, + path: ?[]const u8 = null, + /// An OS sandbox (App Sandbox, Flatpak, Snap): the install and socket may be out of sight. + sandboxed: bool = false, + + /// The process's own, through libc; empty without it. + pub fn fromProcess() Env { + return .{ + .exe = getenv(env_exe), + .profile = getenv(env_profile), + .home = getenv(if (builtin.os.tag == .windows) "USERPROFILE" else "HOME"), + .local_app_data = getenv("LOCALAPPDATA"), + .path = getenv("PATH"), + .sandboxed = getenv("APP_SANDBOX_CONTAINER_ID") != null or + getenv("FLATPAK_ID") != null or getenv("SNAP") != null, + }; + } + + fn getenv(name: [:0]const u8) ?[]const u8 { + if (!builtin.link_libc) return null; + const value = std.c.getenv(name) orelse return null; + const slice = std.mem.span(value); + return if (slice.len == 0) null else slice; + } +}; + +pub const Options = struct { + identity: Identity = .{}, + env: Env = .{}, + /// What the host calls itself in its hello, and its version. + host_name: []const u8 = "host", + host_version: []const u8 = "", + tiers: message.Tiers = .{ .draw_list = true }, + /// The socket, when the host knows it (and cannot run fizzy to ask). + socket_path: ?[]const u8 = null, + /// Start a quiet fizzy when none listens. + start: bool = true, + /// How long `connect` may take from the first dial, a start included. + timeout_ms: u32 = 15_000, +}; + +/// An installed fizzy. On macOS `bundle` is the `.app`, started through `open`. +pub const Install = struct { + exe: []const u8, + bundle: ?[]const u8 = null, +}; + +gpa: std.mem.Allocator, +io: Io, +opts: Options, +socket_path: ?[]u8 = null, +stream: ?Io.net.Stream = null, +endpoint: ?Endpoint = null, + +pub fn init(gpa: std.mem.Allocator, io: Io, opts: Options) Client { + return .{ .gpa = gpa, .io = io, .opts = opts }; +} + +pub fn deinit(self: *Client) void { + self.close(); + if (self.socket_path) |p| self.gpa.free(p); +} + +/// Where fizzy would be installed on `env.os`, likeliest first; not `$PATH` (`onPath`). Touches nothing. +pub fn candidates(arena: std.mem.Allocator, identity: Identity, env: Env) error{OutOfMemory}![]const Install { + var list: std.ArrayList(Install) = .empty; + const sep: []const u8 = if (env.os == .windows) "\\" else "/"; + switch (env.os) { + .macos => { + const roots = [_]?[]const u8{ "/Applications", if (env.home) |h| try join(arena, "/", &.{ h, "Applications" }) else null }; + for (roots) |root_opt| { + const root = root_opt orelse continue; + const app = try std.fmt.allocPrint(arena, "{s}/{s}.app", .{ root, identity.display_name }); + try list.append(arena, .{ .exe = try join(arena, "/", &.{ app, "Contents/MacOS", identity.name }), .bundle = app }); + } + }, + .windows => if (env.local_app_data) |base| { + // Velopack: %LOCALAPPDATA%\\current\.exe. + const exe = try std.fmt.allocPrint(arena, "{s}.exe", .{identity.name}); + try list.append(arena, .{ .exe = try join(arena, sep, &.{ base, identity.name, "current", exe }) }); + }, + else => if (env.home) |h| { + const packed_exe = try join(arena, sep, &.{ h, ".local/share", identity.name, "current", identity.name }); + try list.append(arena, .{ .exe = packed_exe }); + try list.append(arena, .{ .exe = try join(arena, sep, &.{ h, ".local/bin", identity.name }) }); + const image = try std.fmt.allocPrint(arena, "{s}.AppImage", .{identity.name}); + try list.append(arena, .{ .exe = try join(arena, sep, &.{ h, "Applications", image }) }); + }, + } + return list.items; +} + +/// `identity.name` in each `$PATH` directory. Tried after everything else: a bare `fizzy` on the +/// path may be a build or a stray copy, not the install. +fn onPath(arena: std.mem.Allocator, identity: Identity, env: Env) error{OutOfMemory}![]const Install { + var list: std.ArrayList(Install) = .empty; + const path = env.path orelse return list.items; + const sep: []const u8 = if (env.os == .windows) "\\" else "/"; + const exe = if (env.os == .windows) try std.fmt.allocPrint(arena, "{s}.exe", .{identity.name}) else identity.name; + var dirs = std.mem.tokenizeScalar(u8, path, if (env.os == .windows) ';' else ':'); + while (dirs.next()) |dir| try list.append(arena, .{ .exe = try join(arena, sep, &.{ dir, exe }) }); + return list.items; +} + +fn join(arena: std.mem.Allocator, sep: []const u8, parts: []const []const u8) error{OutOfMemory}![]u8 { + return std.mem.join(arena, sep, parts); +} + +/// The installed fizzy. `FIZZY_EXE` is the only place looked when set: a named executable that +/// is not there is not installed. +pub fn locate(io: Io, arena: std.mem.Allocator, identity: Identity, env: Env) Error!Install { + if (env.exe) |exe| return if (exists(io, exe)) .{ .exe = exe } else notFound(env); + for (try candidates(arena, identity, env)) |install| { + if (exists(io, install.exe)) return install; + } + if (lookupSystem(io, arena, identity, env)) |install| return install; + for (try onPath(arena, identity, env)) |install| { + if (exists(io, install.exe)) return install; + } + return notFound(env); +} + +fn notFound(env: Env) Error { + return if (env.sandboxed) error.Sandboxed else error.NotInstalled; +} + +fn exists(io: Io, path: []const u8) bool { + Io.Dir.cwd().access(io, path, .{}) catch return false; + return true; +} + +/// Launch Services (macOS) and the registry's App Paths (Windows) know installs that no fixed +/// place does. +fn lookupSystem(io: Io, arena: std.mem.Allocator, identity: Identity, env: Env) ?Install { + switch (env.os) { + .macos => { + if (std.mem.findAny(u8, identity.bundle_id, "\"\\") != null) return null; + const query = std.fmt.allocPrint(arena, "kMDItemCFBundleIdentifier == \"{s}\"", .{identity.bundle_id}) catch return null; + const out = runCapture(io, arena, &.{ "/usr/bin/mdfind", query }) orelse return null; + const bundle = std.mem.trim(u8, firstLine(out), " \t\r"); + if (bundle.len == 0) return null; + const exe = join(arena, "/", &.{ bundle, "Contents/MacOS", identity.name }) catch return null; + return if (exists(io, exe)) .{ .exe = exe, .bundle = bundle } else null; + }, + .windows => for ([_][]const u8{ "HKCU", "HKLM" }) |hive| { + const key = std.fmt.allocPrint(arena, "{s}\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\{s}.exe", .{ hive, identity.name }) catch return null; + const out = runCapture(io, arena, &.{ "reg", "query", key, "/ve" }) orelse continue; + const exe = parseRegValue(out) orelse continue; + if (exists(io, exe)) return .{ .exe = exe }; + }, + else => {}, + } + return null; +} + +/// The value in `reg query … /ve` output: ` (Default) REG_SZ C:\path\fizzy.exe`. +fn parseRegValue(out: []const u8) ?[]const u8 { + const at = std.mem.find(u8, out, "REG_SZ") orelse return null; + const line = firstLine(out[at + "REG_SZ".len ..]); + const value = std.mem.trim(u8, line, " \t\r\""); + return if (value.len == 0) null else value; +} + +fn firstLine(text: []const u8) []const u8 { + return text[0 .. std.mem.findScalar(u8, text, '\n') orelse text.len]; +} + +/// Stdout of a run that exited 0, or null. +fn runCapture(io: Io, arena: std.mem.Allocator, argv: []const []const u8) ?[]const u8 { + const result = std.process.run(arena, io, .{ .argv = argv, .timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } } }) catch return null; + return switch (result.term) { + .exited => |code| if (code == 0) result.stdout else null, + else => null, + }; +} + +/// The socket `install` listens on: `fizzy --control-path`, with the same profile as a launch. +pub fn controlPath(io: Io, arena: std.mem.Allocator, install: Install, profile: ?[]const u8) Error![]const u8 { + const argv: []const []const u8 = if (profile) |p| + &.{ install.exe, "--control-path", "--profile", p } + else + &.{ install.exe, "--control-path" }; + const result = std.process.run(arena, io, .{ .argv = argv, .timeout = .{ .duration = .{ .raw = .fromSeconds(10), .clock = .awake } } }) catch |err| return spawnError(err); + // A fizzy that predates `--control-path` starts as an editor instead: no path to read. + const ok = switch (result.term) { + .exited => |code| code == 0, + else => false, + }; + const path = std.mem.trim(u8, firstLine(result.stdout), " \t\r"); + if (!ok or path.len == 0) return error.FizzyTooOld; + return path; +} + +fn spawnError(err: anyerror) Error { + return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.AccessDenied, error.PermissionDenied => error.Sandboxed, + error.Timeout => error.TimedOut, + else => error.NotInstalled, + }; +} + +/// Start fizzy without taking the person's focus. The child is left to run on its own. +fn startQuiet(io: Io, arena: std.mem.Allocator, install: Install, profile: ?[]const u8, opts: Options) Error!Started { + var argv: std.ArrayList([]const u8) = .empty; + const via_open = opts.env.os == .macos and install.bundle != null; + if (via_open) { + try argv.appendSlice(arena, &.{ "/usr/bin/open", "-g", "-j", install.bundle.?, "--args" }); + } else try argv.append(arena, install.exe); + try argv.append(arena, "--quiet"); + if (profile) |p| try argv.appendSlice(arena, &.{ "--profile", p }); + const child = std.process.spawn(io, .{ + .argv = argv.items, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + }) catch |err| return spawnError(err); + return .{ .child = child, .via_open = via_open }; +} + +const Started = struct { + child: std.process.Child, + /// `open` returns at once and the app runs on: its exit says nothing of the app. + via_open: bool, + + const Status = enum { running, exited_clean, failed }; + + /// A started fizzy that is not `open`'s runs on after we connect, and its exit would leave a + /// zombie until the host quits: wait for it on a thread of its own. If the thread cannot be + /// made the child is left, as before. + fn reapInBackground(self: *Started) void { + if (comptime builtin.os.tag == .windows or builtin.os.tag == .wasi) return; + if (self.via_open) return; + const pid = self.child.id orelse return; + const thread = std.Thread.spawn(.{}, reap, .{pid}) catch return; + thread.detach(); + self.child.id = null; + } + + fn reap(pid: std.posix.pid_t) void { + var wait_status: i32 = 0; + while (true) { + const rc = std.posix.system.waitpid(pid, &wait_status, 0); + if (std.posix.errno(rc) != .INTR) return; + } + } + + fn status(self: *Started) Status { + if (comptime builtin.os.tag == .windows or builtin.os.tag == .wasi) return .running; + const pid = self.child.id orelse return .running; + var wait_status: i32 = 0; + const rc = std.posix.system.waitpid(pid, &wait_status, std.posix.W.NOHANG); + switch (std.posix.errno(rc)) { + .SUCCESS => {}, + else => return .running, + } + if (rc == 0) return .running; + self.child.id = null; + const raw: u32 = @bitCast(wait_status); + const clean = std.posix.W.IFEXITED(raw) and std.posix.W.EXITSTATUS(raw) == 0; + return if (clean) .exited_clean else .failed; + } +}; + +const Deadline = struct { + io: Io, + at_ns: i96, + + fn init(io: Io, ms: u32) Deadline { + return .{ .io = io, .at_ns = Io.Clock.awake.now(io).nanoseconds + @as(i96, ms) * std.time.ns_per_ms }; + } + + fn remainingMs(self: Deadline) u32 { + const left = self.at_ns - Io.Clock.awake.now(self.io).nanoseconds; + return if (left <= 0) 0 else @intCast(@divTrunc(left + std.time.ns_per_ms - 1, std.time.ns_per_ms)); + } +}; + +fn timeoutMs(ms: u32) Io.Timeout { + return .{ .duration = .{ .raw = .fromMilliseconds(ms), .clock = .awake } }; +} + +/// One read of at most `buf.len` bytes: the count (0 is the end of the stream), or null when +/// `timeout` passed first. +pub fn readSome(io: Io, stream: Io.net.Stream, buf: []u8, timeout: Io.Timeout) error{Closed}!?usize { + var data: [1][]u8 = .{buf}; + const result = io.operateTimeout(.{ .net_read = .{ .socket_handle = stream.socket.handle, .data = &data } }, timeout) catch |err| switch (err) { + error.Timeout => return null, + else => return error.Closed, + }; + const read = result.net_read catch return error.Closed; + return read.data_len; +} + +pub fn writeAll(io: Io, stream: Io.net.Stream, bytes: []const u8) error{Closed}!void { + var buf: [1024]u8 = undefined; + var writer = stream.writer(io, &buf); + writer.interface.writeAll(bytes) catch return error.Closed; + writer.interface.flush() catch return error.Closed; +} + +/// Find, start if need be, connect and say hello. On error nothing stays open. +pub fn connect(self: *Client) Error!void { + self.close(); + var arena_state: std.heap.ArenaAllocator = .init(self.gpa); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const deadline: Deadline = .init(self.io, self.opts.timeout_ms); + const opts = self.opts; + + var install: ?Install = null; + if (opts.socket_path) |p| { + if (self.socket_path == null) self.socket_path = try self.gpa.dupe(u8, p); + } else { + const found = try locate(self.io, arena, opts.identity, opts.env); + install = found; + const path = try controlPath(self.io, arena, found, opts.env.profile); + if (self.socket_path) |old| self.gpa.free(old); + self.socket_path = try self.gpa.dupe(u8, path); + } + const socket = self.socket_path.?; + const addr = Io.net.UnixAddress.init(socket) catch return error.Protocol; + + var started: ?Started = null; + // Whatever ends `connect`, a fizzy we started is waited for when it exits. It is not killed: + // a slow one may still come up and be found by a later `connect`. + defer if (started) |*st| st.reapInBackground(); + var pause_ms: u32 = 10; + while (true) { + if (addr.connect(self.io)) |stream| { + if (self.upgrade(stream, deadline)) |_| return else |err| switch (err) { + // fizzy is quitting or still coming up: a later dial may be served. + error.Closed => {}, + else => return err, + } + } else |err| switch (err) { + error.AccessDenied, error.PermissionDenied => return error.Sandboxed, + else => {}, + } + if (!opts.start) return error.NotRunning; + if (started == null) { + const found = install orelse try locate(self.io, arena, opts.identity, opts.env); + started = try startQuiet(self.io, arena, found, opts.env.profile, opts); + } else if (!started.?.via_open and started.?.status() == .failed) { + return error.StartFailed; + } + const left = deadline.remainingMs(); + if (left == 0) return error.TimedOut; + self.io.sleep(.fromMilliseconds(@min(pause_ms, left)), .awake) catch return error.Closed; + pause_ms = @min(pause_ms * 2, 250); + } +} + +/// `GET /remote` on `stream`, then the hellos. Takes ownership of `stream`. +fn upgrade(self: *Client, stream: Io.net.Stream, deadline: Deadline) Error!void { + errdefer stream.close(self.io); + try writeAll(self.io, stream, "GET /remote HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: " ++ upgrade_token ++ "\r\n\r\n"); + + var head: [2048]u8 = undefined; + var len: usize = 0; + const body_at = while (true) { + if (std.mem.find(u8, head[0..len], "\r\n\r\n")) |at| break at + 4; + if (len == head.len) return error.Protocol; + const n = try readSome(self.io, stream, head[len..], timeoutMs(deadline.remainingMs())) orelse return error.TimedOut; + if (n == 0) return error.Closed; + len += n; + }; + const status_line = firstLine(head[0..len]); + if (std.mem.startsWith(u8, status_line, "HTTP/1.1 503")) return error.Closed; + if (!std.mem.startsWith(u8, status_line, "HTTP/1.1 101")) return error.FizzyTooOld; + + var ep: Endpoint = .init(self.gpa, .host, .{ + .app_id = self.opts.host_name, + .app_version = self.opts.host_version, + .tiers_offered = self.opts.tiers, + .tiers_wanted = self.opts.tiers, + }); + errdefer ep.deinit(); + ep.start() catch |err| return epError(err); + try ep.feed(head[body_at..len]); + self.stream = stream; + errdefer self.stream = null; + try self.flush(&ep); + + var arena_state: std.heap.ArenaAllocator = .init(self.gpa); + defer arena_state.deinit(); + var buf: [4096]u8 = undefined; + while (true) { + const got = ep.poll(arena_state.allocator()) catch |err| { + self.flush(&ep) catch {}; + return skewError(&ep, err); + }; + if (got) |msg| switch (msg) { + .hello => break, + .bye => |bye| return switch (bye.reason) { + .your_side_too_old => error.ClientTooOld, + .your_side_too_new => error.FizzyTooOld, + else => error.Closed, + }, + else => return error.Protocol, + }; + const n = try readSome(self.io, stream, &buf, timeoutMs(deadline.remainingMs())) orelse return error.TimedOut; + if (n == 0) return error.Closed; + try ep.feed(buf[0..n]); + } + self.endpoint = ep; +} + +fn epError(err: Endpoint.Error) Error { + return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.Closed => error.Closed, + else => error.Protocol, + }; +} + +fn skewError(ep: *const Endpoint, err: Endpoint.Error) Error { + return switch (err) { + error.VersionSkew => if (ep.conn.skew == .host) error.ClientTooOld else error.FizzyTooOld, + error.OutOfMemory => error.OutOfMemory, + else => error.Protocol, + }; +} + +fn flush(self: *Client, ep: *Endpoint) Error!void { + const stream = self.stream orelse return error.Closed; + if (ep.pending().len == 0) return; + try writeAll(self.io, stream, ep.pending()); + ep.consume(ep.pending().len); +} + +/// Whether a stream is open (a drop shows on the next `poll` or `send`). +pub fn connected(self: *const Client) bool { + return self.endpoint != null and self.stream != null; +} + +/// Check `msg` against the conversation's rules and send it. A message the rules refuse is the +/// caller's mistake: it is not sent and the stream stays open. A write that fails closes the client. +pub fn send(self: *Client, msg: message.Message) Error!void { + const ep = &(self.endpoint orelse return error.Closed); + if (self.stream == null) return self.closeStale(); + ep.send(msg) catch |err| return epError(err); + self.flush(ep) catch |err| { + self.close(); + return err; + }; +} + +/// The next message from fizzy, waiting up to `wait_ms` (null: forever); null when that passes. +/// Lists are allocated from `arena`; strings borrow the client until the next `poll`, `send`, +/// `reconnect` or `close`, after which they dangle (a `bye`'s too: it borrows the endpoint, which +/// outlives the stream until then). Whatever ends the stream closes the client, and `reconnect` +/// opens it again. +pub fn poll(self: *Client, arena: std.mem.Allocator, wait_ms: ?u32) Error!?message.Message { + const ep = &(self.endpoint orelse return error.Closed); + const stream = self.stream orelse return self.closeStale(); + const deadline: ?Deadline = if (wait_ms) |ms| .init(self.io, ms) else null; + var buf: [16 * 1024]u8 = undefined; + while (true) { + const got = ep.poll(arena) catch |err| { + self.flush(ep) catch {}; + const failure = skewError(ep, err); + self.close(); + return failure; + }; + if (got) |msg| { + if (msg == .bye) self.closeAfterBye(); + return msg; + } + const timeout: Io.Timeout = if (deadline) |d| timeoutMs(d.remainingMs()) else .none; + const n = readSome(self.io, stream, &buf, timeout) catch |err| { + self.close(); + return err; + } orelse return null; + if (n == 0) { + self.close(); + return error.Closed; + } + ep.feed(buf[0..n]) catch |err| { + self.close(); + return err; + }; + } +} + +/// The peer ended the conversation; its bye is the caller's to read, the stream is done. The +/// endpoint stays until the next `poll`, `send`, `reconnect` or `close`, because the bye's +/// strings borrow it and dangle once it is gone. +fn closeAfterBye(self: *Client) void { + if (self.stream) |s| s.close(self.io); + self.stream = null; +} + +/// The stream ended with a bye already delivered: finish closing. +fn closeStale(self: *Client) error{Closed} { + self.close(); + return error.Closed; +} + +/// Open again after a drop, finding and starting fizzy as `connect` does. Views are gone with +/// the old stream: the host creates them again. +pub fn reconnect(self: *Client) Error!void { + return self.connect(); +} + +pub fn close(self: *Client) void { + if (self.endpoint) |*ep| ep.deinit(); + self.endpoint = null; + if (self.stream) |s| s.close(self.io); + self.stream = null; +} + +test candidates { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const id: Identity = .{}; + + const mac = try candidates(arena, id, .{ .os = .macos, .home = "/Users/a", .path = "/usr/bin:/opt/bin" }); + try std.testing.expectEqualStrings("/Applications/Fizzy.app/Contents/MacOS/fizzy", mac[0].exe); + try std.testing.expectEqualStrings("/Applications/Fizzy.app", mac[0].bundle.?); + try std.testing.expectEqualStrings("/Users/a/Applications/Fizzy.app", mac[1].bundle.?); + try std.testing.expectEqual(@as(usize, 2), mac.len); + const mac_path = try onPath(arena, id, .{ .os = .macos, .path = "/usr/bin:/opt/bin" }); + try std.testing.expectEqualStrings("/opt/bin/fizzy", mac_path[1].exe); + + const win = try candidates(arena, id, .{ .os = .windows, .local_app_data = "C:\\Users\\a\\AppData\\Local", .path = "C:\\bin;D:\\tools" }); + try std.testing.expectEqualStrings("C:\\Users\\a\\AppData\\Local\\fizzy\\current\\fizzy.exe", win[0].exe); + const win_path = try onPath(arena, id, .{ .os = .windows, .path = "C:\\bin;D:\\tools" }); + try std.testing.expectEqualStrings("D:\\tools\\fizzy.exe", win_path[1].exe); + + const linux = try candidates(arena, .{ .name = "paint" }, .{ .os = .linux, .home = "/home/a", .path = "/usr/bin" }); + try std.testing.expectEqualStrings("/home/a/.local/share/paint/current/paint", linux[0].exe); + try std.testing.expectEqualStrings("/home/a/Applications/paint.AppImage", linux[2].exe); + try std.testing.expectEqual(@as(usize, 3), linux.len); + try std.testing.expectEqualStrings("/usr/bin/paint", (try onPath(arena, .{ .name = "paint" }, .{ .os = .linux, .path = "/usr/bin" }))[0].exe); +} + +test parseRegValue { + const out = "\r\nHKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\fizzy.exe\r\n (Default) REG_SZ C:\\Users\\a\\AppData\\Local\\fizzy\\current\\fizzy.exe\r\n"; + try std.testing.expectEqualStrings("C:\\Users\\a\\AppData\\Local\\fizzy\\current\\fizzy.exe", parseRegValue(out).?); + try std.testing.expect(parseRegValue("ERROR: The system was unable to find the specified registry key") == null); +} diff --git a/sdk/remote/README.md b/sdk/remote/README.md index c2ed16ab1..915f301ae 100644 --- a/sdk/remote/README.md +++ b/sdk/remote/README.md @@ -69,3 +69,29 @@ while (try ep.poll(arena)) |msg| { ... } // arena for lists; strings borrow un ``` `Endpoint.pump(a, b, chunk)` moves one end's bytes to the other for a loopback in tests. + +## Finding fizzy (`Client`, `fizzy_remote.h`) + +`Client.zig` is what an adapter calls first: find the installed fizzy, start it without taking +focus when nothing listens, connect to its control socket and upgrade to this stream. `capi.zig` +exports it as the C API in `fizzy_remote.h` (open, reconnect, send/recv whole frames, close). + +- **Where fizzy is:** `FIZZY_EXE`, else per OS: macOS `/Applications/Fizzy.app`, + `~/Applications`, then Launch Services by bundle id (`mdfind`); Windows the Velopack folder + under `%LOCALAPPDATA%\\current`, then the registry's App Paths; Linux Velopack under + `~/.local/share`, `~/.local/bin`, an AppImage in `~/Applications`; then `PATH`. An app built on + fizzy passes its own name, display name and bundle id (`Identity`). +- **Its socket:** `fizzy --control-path` (with `--profile`, or `FIZZY_PROFILE`), unless the host + names it. +- **Starting it:** `fizzy --quiet` (macOS: `open -g -j --args --quiet`), then dialing + with a pause growing from 10 to 250 ms until `timeout_ms`. +- **Errors say what the person does:** `NotInstalled`, `FizzyTooOld` (update fizzy: no `/remote`, + or older versions), `ClientTooOld` (update the add-on), `TimedOut`, `StartFailed` (the start + exited with a failure), and `Sandboxed`: an App Sandbox, Flatpak or Snap host that sees neither + fizzy nor its socket, or may not start it. It never starts a private second fizzy to get around + that; the host asks the person for the socket (or an entitlement) instead. +- **After a drop** `poll`/`send` return `Closed` and `reconnect` runs the same steps again. + +Tested against `fake_fizzy.zig`, an executable that listens on a test socket (Linux CI). Discovery on a real +macOS or Windows install (`candidates` is tested as strings; `mdfind`, `reg query` and `open -g -j` +are not) has not been tried by hand. diff --git a/sdk/remote/capi.zig b/sdk/remote/capi.zig new file mode 100644 index 000000000..66a25cb20 --- /dev/null +++ b/sdk/remote/capi.zig @@ -0,0 +1,282 @@ +//! The C ABI over `Client` (`fizzy_remote.h`): an adapter in a host application (Blender, +//! Unreal, ...) finds, starts and connects to the installed fizzy and exchanges whole frames with +//! it. A frame is the wire's (`README.md`): the adapter's encoder/decoder is the generated half +//! this file leaves out, so what crosses here is bytes. +//! +//! Calls on one handle are not thread safe; two handles are independent. +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const message = @import("message.zig"); +const codec = @import("codec.zig"); +const FrameReader = @import("FrameReader.zig"); +const Client = @import("Client.zig"); + +/// `fizzy_remote_status` in the header: the numbers are the ABI. +const Status = enum(c_int) { + ok = 0, + not_installed = 1, + not_running = 2, + sandboxed = 3, + fizzy_too_old = 4, + client_too_old = 5, + timed_out = 6, + start_failed = 7, + protocol = 8, + closed = 9, + out_of_memory = 10, + buffer_too_small = 11, + invalid_argument = 12, +}; + +fn status(err: Client.Error) c_int { + return @backingInt(switch (err) { + error.NotInstalled => Status.not_installed, + error.NotRunning => .not_running, + error.Sandboxed => .sandboxed, + error.FizzyTooOld => .fizzy_too_old, + error.ClientTooOld => .client_too_old, + error.TimedOut => .timed_out, + error.StartFailed => .start_failed, + error.Protocol => .protocol, + error.Closed => .closed, + error.OutOfMemory => .out_of_memory, + }); +} + +/// Every pointer is a NUL-terminated string or null (the default or the environment's). +pub const Options = extern struct { + /// `sizeof` this struct, so a later field can be added without breaking a built adapter. + size: u32, + timeout_ms: u32, + /// `FIZZY_REMOTE_NO_START` (1): connect to a running fizzy, never start one. + flags: u32, + host_name: ?[*:0]const u8, + host_version: ?[*:0]const u8, + /// The application to find, for an app built on fizzy: executable/pack id, macOS bundle name + /// and bundle id. Fizzy's own when null. + app_name: ?[*:0]const u8, + app_display_name: ?[*:0]const u8, + app_bundle_id: ?[*:0]const u8, + /// Override `FIZZY_EXE`, `FIZZY_PROFILE`; and the socket itself, for a host that cannot run + /// fizzy to ask. + exe: ?[*:0]const u8, + profile: ?[*:0]const u8, + socket_path: ?[*:0]const u8, +}; + +const no_start_flag: u32 = 1; + +const Handle = struct { + arena: std.heap.ArenaAllocator, + threaded: Io.Threaded, + client: Client, + /// An encoded message `recv` could not fit in the caller's buffer yet. + stash: std.ArrayList(u8) = .empty, + socket_z: ?[:0]u8 = null, + + fn refreshSocket(self: *Handle) void { + const path = self.client.socket_path orelse return; + self.socket_z = self.arena.allocator().dupeSentinel(u8, path, 0) catch return; + } +}; + +const gpa = std.heap.smp_allocator; + +fn dup(a: std.mem.Allocator, s: ?[*:0]const u8) error{OutOfMemory}!?[]const u8 { + return if (s) |p| try a.dupe(u8, std.mem.span(p)) else null; +} + +/// Find, start and connect: `*out` is a handle to close, on `ok` only. +export fn fizzy_remote_open(options: ?*const Options, out: ?*?*Handle) callconv(.c) c_int { + const o = options orelse return @backingInt(Status.invalid_argument); + const result = out orelse return @backingInt(Status.invalid_argument); + result.* = null; + if (o.size < @offsetOf(Options, "socket_path") + @sizeOf(?[*:0]const u8)) return @backingInt(Status.invalid_argument); + + const handle = gpa.create(Handle) catch return @backingInt(Status.out_of_memory); + handle.* = .{ .arena = .init(gpa), .threaded = .init(gpa, .{ .environ = processEnviron() }), .client = undefined }; + var started = false; + defer if (!started) { + handle.arena.deinit(); + handle.threaded.deinit(); + gpa.destroy(handle); + }; + const a = handle.arena.allocator(); + + const opts = clientOptions(a, o) catch return @backingInt(Status.out_of_memory); + + handle.client = .init(gpa, handle.threaded.io(), opts); + handle.client.connect() catch |err| { + handle.client.deinit(); + return status(err); + }; + handle.refreshSocket(); + started = true; + result.* = handle; + return 0; +} + +fn clientOptions(a: std.mem.Allocator, o: *const Options) error{OutOfMemory}!Client.Options { + var opts: Client.Options = .{ .env = .fromProcess() }; + if (try dup(a, o.exe)) |v| opts.env.exe = v; + if (try dup(a, o.profile)) |v| opts.env.profile = v; + if (try dup(a, o.app_name)) |v| opts.identity.name = v; + if (try dup(a, o.app_display_name)) |v| opts.identity.display_name = v; + if (try dup(a, o.app_bundle_id)) |v| opts.identity.bundle_id = v; + if (try dup(a, o.host_name)) |v| opts.host_name = v; + if (try dup(a, o.host_version)) |v| opts.host_version = v; + opts.socket_path = try dup(a, o.socket_path); + opts.start = o.flags & no_start_flag == 0; + if (o.timeout_ms != 0) opts.timeout_ms = o.timeout_ms; + return opts; +} + +fn processEnviron() std.process.Environ { + if (comptime !builtin.link_libc or builtin.os.tag == .windows) return .empty; + var n: usize = 0; + while (std.c.environ[n] != null) n += 1; + return .{ .block = .{ .slice = std.c.environ[0..n :null] } }; +} + +/// Connect again after a drop (`recv`/`send` returned `closed`), starting fizzy if it quit. +export fn fizzy_remote_reconnect(handle: ?*Handle) callconv(.c) c_int { + const h = handle orelse return @backingInt(Status.invalid_argument); + h.client.connect() catch |err| return status(err); + h.refreshSocket(); + return 0; +} + +/// Send one whole frame. +export fn fizzy_remote_send(handle: ?*Handle, frame: ?[*]const u8, len: usize) callconv(.c) c_int { + const h = handle orelse return @backingInt(Status.invalid_argument); + const bytes = (frame orelse return @backingInt(Status.invalid_argument))[0..len]; + var reader: FrameReader = .init(gpa); + defer reader.deinit(); + reader.push(bytes) catch return @backingInt(Status.out_of_memory); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const frame_in = (reader.next() catch return @backingInt(Status.protocol)) orelse return @backingInt(Status.protocol); + const msg = (codec.decode(arena.allocator(), frame_in) catch return @backingInt(Status.protocol)) orelse return @backingInt(Status.protocol); + h.client.send(msg) catch |err| return status(err); + return 0; +} + +/// The next frame from fizzy into `buf`, waiting up to `timeout_ms` (negative: forever). +/// `*len` is its length; 0 with `ok` means the wait passed. A frame larger than `cap` is kept: +/// `buffer_too_small` with `*len` the size needed, then call again with room. +export fn fizzy_remote_recv(handle: ?*Handle, buf: ?[*]u8, cap: usize, len: ?*usize, timeout_ms: i32) callconv(.c) c_int { + const h = handle orelse return @backingInt(Status.invalid_argument); + const out_len = len orelse return @backingInt(Status.invalid_argument); + out_len.* = 0; + if (h.stash.items.len == 0) { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const wait: ?u32 = if (timeout_ms < 0) null else @intCast(timeout_ms); + const msg = (h.client.poll(arena.allocator(), wait) catch |err| return status(err)) orelse return 0; + codec.encode(gpa, &h.stash, msg, codec.default_max_frame) catch return @backingInt(Status.protocol); + } + out_len.* = h.stash.items.len; + if (h.stash.items.len > cap or buf == null) return @backingInt(Status.buffer_too_small); + @memcpy(buf.?[0..h.stash.items.len], h.stash.items); + h.stash.clearRetainingCapacity(); + return 0; +} + +/// The control socket in use, valid until the next call on the handle; null before a connect. +export fn fizzy_remote_socket_path(handle: ?*const Handle) callconv(.c) ?[*:0]const u8 { + const h = handle orelse return null; + return if (h.socket_z) |p| p.ptr else null; +} + +/// A short English reason for a status, for a host's log; never null. +export fn fizzy_remote_status_string(code: c_int) callconv(.c) [*:0]const u8 { + return switch (std.enums.fromInt(Status, code) orelse return "unknown status") { + .ok => "ok", + .not_installed => "fizzy is not installed", + .not_running => "fizzy is not running", + .sandboxed => "this app's sandbox hides or forbids fizzy", + .fizzy_too_old => "fizzy is too old: update fizzy", + .client_too_old => "this add-on is too old: update it", + .timed_out => "fizzy did not answer in time", + .start_failed => "fizzy exited while starting", + .protocol => "fizzy did not speak the protocol", + .closed => "the connection closed", + .out_of_memory => "out of memory", + .buffer_too_small => "the buffer is too small", + .invalid_argument => "invalid argument", + }; +} + +/// The newest protocol version this library speaks. +export fn fizzy_remote_protocol_version() callconv(.c) u32 { + return message.max_version; +} + +/// End the connection and free the handle. Fizzy keeps running. +export fn fizzy_remote_close(handle: ?*Handle) callconv(.c) void { + const h = handle orelse return; + h.client.deinit(); + h.stash.deinit(gpa); + h.threaded.deinit(); + h.arena.deinit(); + gpa.destroy(h); +} + +const t = std.testing; + +test "the C ABI opens, round trips a frame and closes" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = t.tmpDir(.{}); + defer tmp.cleanup(); + const profile = try tmp.dir.realPathFileAlloc(t.io, ".", t.allocator); + defer t.allocator.free(profile); + try tmp.dir.writeFile(t.io, .{ .sub_path = "mode", .data = "serve" }); + + var opts = std.mem.zeroes(Options); + opts.size = @sizeOf(Options); + opts.timeout_ms = 5000; + opts.host_name = "c-test"; + const fake = try t.allocator.dupeSentinel(u8, @import("client_tests.zig").fake_exe, 0); + defer t.allocator.free(fake); + opts.exe = fake; + opts.profile = profile; + var handle: ?*Handle = null; + try t.expectEqual(@as(c_int, 0), fizzy_remote_open(&opts, &handle)); + defer fizzy_remote_close(handle); + try t.expect(std.mem.endsWith(u8, std.mem.span(fizzy_remote_socket_path(handle).?), "control.sock")); + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(t.allocator); + try codec.encode(t.allocator, &out, .{ .create_view = .{ .view = 3, .width = 64, .height = 48 } }, codec.default_max_frame); + try t.expectEqual(@as(c_int, 0), fizzy_remote_send(handle, out.items.ptr, out.items.len)); + + var small: [4]u8 = undefined; + var len: usize = 0; + try t.expectEqual(@backingInt(Status.buffer_too_small), fizzy_remote_recv(handle, &small, small.len, &len, 5000)); + try t.expect(len > small.len); + var frame: [256]u8 = undefined; + try t.expectEqual(@as(c_int, 0), fizzy_remote_recv(handle, &frame, frame.len, &len, 5000)); + var reader: FrameReader = .init(t.allocator); + defer reader.deinit(); + try reader.push(frame[0..len]); + var arena: std.heap.ArenaAllocator = .init(t.allocator); + defer arena.deinit(); + const created = (try codec.decode(arena.allocator(), (try reader.next()).?)).?.view_created; + try t.expectEqual(@as(u32, 3), created.view); + + try t.expectEqual(@as(c_int, 0), fizzy_remote_recv(handle, &frame, frame.len, &len, 20)); + try t.expectEqual(@as(usize, 0), len); +} + +test "the C ABI says why it could not open" { + var opts = std.mem.zeroes(Options); + opts.size = @sizeOf(Options); + opts.exe = "/nonexistent/fizzy"; + var handle: ?*Handle = null; + const code = fizzy_remote_open(&opts, &handle); + try t.expectEqual(@backingInt(Status.not_installed), code); + try t.expect(handle == null); + try t.expectEqualStrings("fizzy is not installed", std.mem.span(fizzy_remote_status_string(code))); + try t.expectEqual(@backingInt(Status.invalid_argument), fizzy_remote_open(null, &handle)); +} diff --git a/sdk/remote/client_tests.zig b/sdk/remote/client_tests.zig new file mode 100644 index 000000000..c60a34699 --- /dev/null +++ b/sdk/remote/client_tests.zig @@ -0,0 +1,215 @@ +//! `Client` against `fake_fizzy.zig`, a stand-in executable that listens on a test socket. The +//! build compiles it and names its path in the `fake_fizzy_options` import, so these run under +//! `zig build test`, not a bare `zig test`. POSIX only: the fake's socket and the child's exit +//! status are skipped on Windows. +const std = @import("std"); +const builtin = @import("builtin"); +const Io = std.Io; +const remote = @import("root.zig"); +const Client = remote.Client; +const message = remote.message; + +const t = std.testing; + +/// The compiled `fake_fizzy.zig`. +pub const fake_exe: []const u8 = @import("fake_fizzy_options").fake_fizzy; + +const Fixture = struct { + tmp: t.TmpDir, + profile: [:0]u8, + fake: []const u8, + + fn init(mode: []const u8) !Fixture { + if (builtin.os.tag == .windows) return error.SkipZigTest; + var tmp = t.tmpDir(.{}); + errdefer tmp.cleanup(); + const profile = try tmp.dir.realPathFileAlloc(t.io, ".", t.allocator); + errdefer t.allocator.free(profile); + try tmp.dir.writeFile(t.io, .{ .sub_path = "mode", .data = mode }); + return .{ .tmp = tmp, .profile = profile, .fake = fake_exe }; + } + + fn deinit(self: *Fixture) void { + t.allocator.free(self.profile); + self.tmp.cleanup(); + } + + fn options(self: *const Fixture) Client.Options { + return .{ .env = .{ .exe = self.fake, .profile = self.profile }, .host_name = "test-host", .timeout_ms = 5000 }; + } + + /// How many times the fake was started with `--quiet`. + fn starts(self: *const Fixture) usize { + const text = self.tmp.dir.readFileAlloc(t.io, "launches", t.allocator, .limited(4096)) catch return 0; + defer t.allocator.free(text); + return std.mem.count(u8, text, "start\n"); + } +}; + +/// A view round trip: proof the stream carries the conversation past the hellos. +fn roundTrip(client: *Client, view: message.ViewId) !void { + var arena_state: std.heap.ArenaAllocator = .init(t.allocator); + defer arena_state.deinit(); + try client.send(.{ .create_view = .{ .view = view, .width = 320, .height = 200 } }); + const created = (try client.poll(arena_state.allocator(), 5000)).?.view_created; + try t.expectEqual(view, created.view); + try t.expectEqual(@as(u32, 320), created.width); +} + +test "a fizzy that is already listening is found, not started again" { + var fx = try Fixture.init("serve"); + defer fx.deinit(); + var child = try std.process.spawn(t.io, .{ + .argv = &.{ fx.fake, "--quiet", "--profile", fx.profile }, + .stdin = .ignore, + .stdout = .ignore, + .stderr = .ignore, + }); + defer _ = child.wait(t.io) catch {}; + var listening = false; + for (0..500) |_| { + if (fx.tmp.dir.access(t.io, "control.sock", .{})) |_| { + listening = true; + break; + } else |_| try t.io.sleep(.fromMilliseconds(10), .awake); + } + try t.expect(listening); + + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try client.connect(); + try roundTrip(&client, 1); + try t.expectEqual(@as(usize, 1), fx.starts()); +} + +test "nothing listening: fizzy is started quietly and connected to" { + var fx = try Fixture.init("serve"); + defer fx.deinit(); + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try client.connect(); + try roundTrip(&client, 7); + try t.expectEqual(@as(usize, 1), fx.starts()); +} + +test "not installed, not running, and sandboxed are three errors" { + var fx = try Fixture.init("serve"); + defer fx.deinit(); + const nowhere = try std.fmt.allocPrint(t.allocator, "{s}/nope", .{fx.profile}); + defer t.allocator.free(nowhere); + + var opts = fx.options(); + opts.env.exe = nowhere; + var client: Client = .init(t.allocator, t.io, opts); + defer client.deinit(); + try t.expectError(error.NotInstalled, client.connect()); + + // A search that finds nothing in a home with nothing in it. + opts.env = .{ .os = .linux, .home = fx.profile, .path = fx.profile }; + client.opts = opts; + try t.expectError(error.NotInstalled, client.connect()); + client.opts.env.sandboxed = true; + try t.expectError(error.Sandboxed, client.connect()); + try t.expectEqual(@as(usize, 0), fx.starts()); + + // A named socket nobody listens on, and no start asked for. + opts.env = .{}; + opts.socket_path = nowhere; + opts.start = false; + client.opts = opts; + try t.expectError(error.NotRunning, client.connect()); +} + +test "a slow start is waited for, a too slow one times out and is found later" { + var fx = try Fixture.init("slow 300"); + defer fx.deinit(); + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try client.connect(); + try roundTrip(&client, 1); + client.close(); + + var slow = try Fixture.init("slow 2500"); + defer slow.deinit(); + var opts = slow.options(); + opts.timeout_ms = 1000; + var late: Client = .init(t.allocator, t.io, opts); + defer late.deinit(); + try t.expectError(error.TimedOut, late.connect()); + // The process may not have run far enough to note its start when the deadline passed. + for (0..100) |_| { + if (slow.starts() != 0) break; + try t.io.sleep(.fromMilliseconds(50), .awake); + } + try t.expectEqual(@as(usize, 1), slow.starts()); + late.opts.start = false; + late.opts.timeout_ms = 5000; + // Not started a second time: the first one finishes starting and is found. + var found = false; + for (0..100) |_| { + late.connect() catch { + try t.io.sleep(.fromMilliseconds(50), .awake); + continue; + }; + found = true; + break; + } + try t.expect(found); + try t.expectEqual(@as(usize, 1), slow.starts()); +} + +test "a fizzy that dies while starting is StartFailed" { + var fx = try Fixture.init("crash"); + defer fx.deinit(); + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try t.expectError(error.StartFailed, client.connect()); + try t.expectEqual(@as(usize, 1), fx.starts()); +} + +test "versions: a fizzy from the future or the past says who updates" { + var newer = try Fixture.init("old"); + defer newer.deinit(); + var client: Client = .init(t.allocator, t.io, newer.options()); + defer client.deinit(); + try t.expectError(error.ClientTooOld, client.connect()); + try t.expect(!client.connected()); + + var no_remote = try Fixture.init("noremote"); + defer no_remote.deinit(); + client.opts = no_remote.options(); + try t.expectError(error.FizzyTooOld, client.connect()); +} + +test "a dropped stream closes the client and reconnect opens it again" { + var fx = try Fixture.init("drop"); + defer fx.deinit(); + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try client.connect(); + var arena_state: std.heap.ArenaAllocator = .init(t.allocator); + defer arena_state.deinit(); + try t.expectError(error.Closed, client.poll(arena_state.allocator(), 5000)); + try t.expect(!client.connected()); + try t.expectError(error.Closed, client.send(.{ .create_view = .{ .view = 1, .width = 1, .height = 1 } })); + + try client.reconnect(); + try roundTrip(&client, 1); + try t.expectEqual(@as(usize, 1), fx.starts()); +} + +test "polling after a bye closes the client instead of crashing" { + var fx = try Fixture.init("bye"); + defer fx.deinit(); + var client: Client = .init(t.allocator, t.io, fx.options()); + defer client.deinit(); + try client.connect(); + var arena_state: std.heap.ArenaAllocator = .init(t.allocator); + defer arena_state.deinit(); + try client.send(.{ .create_view = .{ .view = 1, .width = 1, .height = 1 } }); + const bye = (try client.poll(arena_state.allocator(), 5000)).?.bye; + try t.expectEqualStrings("going away", bye.text); + try t.expect(!client.connected()); + try t.expectError(error.Closed, client.poll(arena_state.allocator(), 100)); + try t.expectError(error.Closed, client.send(.{ .create_view = .{ .view = 2, .width = 1, .height = 1 } })); +} diff --git a/sdk/remote/fake_fizzy.zig b/sdk/remote/fake_fizzy.zig new file mode 100644 index 000000000..6324717b4 --- /dev/null +++ b/sdk/remote/fake_fizzy.zig @@ -0,0 +1,120 @@ +//! A stand-in for the fizzy executable, for `client_tests.zig`: answers `--control-path`, and +//! otherwise listens on `/control.sock` and speaks the editor's side of the remote-view +//! stream. How it behaves is the word in `/mode`, so a test sets it before the client +//! starts it: +//! +//! * `serve` — listen at once. +//! * `slow ` — listen after a pause. +//! * `crash` — exit 3 before listening. +//! * `old` — hello with versions the client lacks. +//! * `noremote` — answer `GET /remote` with 404, as a fizzy without a remote view does. +//! * `bye` — answer `create_view` with a bye. +//! * `drop` — close the first connection right after the hellos; the second is served. +//! +//! Each `--quiet` start appends a line to `/launches`. It answers `create_view` with +//! `view_created`, serves one connection (two for `drop`) and exits. +const std = @import("std"); +const Io = std.Io; +const remote = @import("root.zig"); +const Client = remote.Client; +const Endpoint = remote.Endpoint; +const message = remote.message; + +const Mode = enum { serve, slow, crash, old, noremote, drop, bye }; + +pub fn main(init: std.process.Init) !void { + const gpa = init.gpa; + const io = init.io; + var profile: ?[]const u8 = null; + var control_path = false; + var quiet = false; + var args = try std.process.Args.Iterator.initAllocator(init.minimal.args, gpa); + defer args.deinit(); + _ = args.next(); + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--control-path")) control_path = true; + if (std.mem.eql(u8, arg, "--quiet")) quiet = true; + if (std.mem.eql(u8, arg, "--profile")) profile = args.next(); + } + const dir = profile orelse std.process.exit(1); + const sock = try std.fmt.allocPrint(gpa, "{s}/control.sock", .{dir}); + + if (control_path) { + var buf: [512]u8 = undefined; + var out = Io.File.stdout().writer(io, &buf); + try out.interface.print("{s}\n", .{sock}); + try out.interface.flush(); + return; + } + if (quiet) try appendLaunch(gpa, io, dir); + + const text = try Io.Dir.cwd().readFileAlloc(io, try std.fmt.allocPrint(gpa, "{s}/mode", .{dir}), gpa, .limited(256)); + var words = std.mem.tokenizeAny(u8, text, " \r\n"); + const mode = std.meta.stringToEnum(Mode, words.next() orelse "serve") orelse .serve; + switch (mode) { + .crash => std.process.exit(3), + .slow => try io.sleep(.fromMilliseconds(try std.fmt.parseInt(i64, words.next() orelse "300", 10)), .awake), + else => {}, + } + // A test that fails halfway leaves no fake behind. + _ = io.concurrent(selfDestruct, .{io}) catch {}; + + Io.Dir.deleteFileAbsolute(io, sock) catch {}; + var server = try (try Io.net.UnixAddress.init(sock)).listen(io, .{}); + defer server.deinit(io); + const connections: usize = if (mode == .drop) 2 else 1; + for (0..connections) |n| { + const stream = try server.accept(io); + defer stream.close(io); + talk(gpa, io, stream, mode, n) catch {}; + } +} + +fn selfDestruct(io: Io) void { + io.sleep(.fromSeconds(30), .awake) catch {}; + std.process.exit(0); +} + +fn appendLaunch(gpa: std.mem.Allocator, io: Io, dir: []const u8) !void { + const path = try std.fmt.allocPrint(gpa, "{s}/launches", .{dir}); + const before = Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(4096)) catch ""; + try Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = try std.fmt.allocPrint(gpa, "{s}start\n", .{before}) }); +} + +fn talk(gpa: std.mem.Allocator, io: Io, stream: Io.net.Stream, mode: Mode, n: usize) !void { + var head: [2048]u8 = undefined; + var len: usize = 0; + const body_at = while (true) { + if (std.mem.find(u8, head[0..len], "\r\n\r\n")) |at| break at + 4; + const got = try Client.readSome(io, stream, head[len..], .none) orelse return; + if (got == 0) return; + len += got; + }; + if (mode == .noremote) return Client.writeAll(io, stream, "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n"); + try Client.writeAll(io, stream, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: " ++ Client.upgrade_token ++ "\r\n\r\n"); + + var ep: Endpoint = .init(gpa, .editor, .{ .app_id = "fizzy-fake", .mode = .shared, .tiers_offered = .{ .draw_list = true }, .version_min = if (mode == .old) 9 else message.min_version, .version_max = if (mode == .old) 9 else message.max_version }); + defer ep.deinit(); + try ep.start(); + try ep.feed(head[body_at..len]); + var arena_state: std.heap.ArenaAllocator = .init(gpa); + defer arena_state.deinit(); + var buf: [4096]u8 = undefined; + while (true) { + _ = arena_state.reset(.retain_capacity); + while (ep.poll(arena_state.allocator()) catch null) |msg| switch (msg) { + .hello => if (mode == .drop and n == 0) { + try Client.writeAll(io, stream, ep.pending()); + return; + }, + .create_view => |cv| if (mode == .bye) try ep.send(.{ .bye = .{ .reason = .shutdown, .text = "going away" } }) else try ep.send(.{ .view_created = .{ .view = cv.view, .tier = .draw_list, .width = cv.width, .height = cv.height } }), + .bye => return, + else => {}, + }; + try Client.writeAll(io, stream, ep.pending()); + ep.consume(ep.pending().len); + const got = try Client.readSome(io, stream, &buf, .none) orelse return; + if (got == 0) return; + try ep.feed(buf[0..got]); + } +} diff --git a/sdk/remote/fizzy_remote.h b/sdk/remote/fizzy_remote.h new file mode 100644 index 000000000..e749430f9 --- /dev/null +++ b/sdk/remote/fizzy_remote.h @@ -0,0 +1,109 @@ +/* fizzy_remote.h — find, quietly start and connect to the installed fizzy. + * + * The C ABI of sdk/remote/capi.zig. A host application's adapter (Blender, Unreal, ...) opens a + * handle, then sends and receives whole frames of the remote-view protocol (sdk/remote/README.md). + * Calls on one handle are not thread safe; two handles are independent. Every function returns a + * fizzy_remote_status, 0 on success. + * + * fizzy_remote_options o = {0}; + * o.size = sizeof o; + * o.host_name = "blender"; + * fizzy_remote *r; + * int st = fizzy_remote_open(&o, &r); + * if (st != FIZZY_REMOTE_OK) show(fizzy_remote_status_string(st)); + */ +#ifndef FIZZY_REMOTE_H +#define FIZZY_REMOTE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct fizzy_remote fizzy_remote; + +typedef enum fizzy_remote_status { + FIZZY_REMOTE_OK = 0, + /* No fizzy where one would be installed. */ + FIZZY_REMOTE_NOT_INSTALLED = 1, + /* Nothing listens and FIZZY_REMOTE_NO_START was set. */ + FIZZY_REMOTE_NOT_RUNNING = 2, + /* This process's sandbox hides or forbids fizzy. A private second fizzy is never started. */ + FIZZY_REMOTE_SANDBOXED = 3, + /* Fizzy has no remote view or speaks only older protocols: the person updates fizzy. */ + FIZZY_REMOTE_FIZZY_TOO_OLD = 4, + /* Fizzy speaks only newer protocols: the person updates this add-on. */ + FIZZY_REMOTE_CLIENT_TOO_OLD = 5, + /* Fizzy did not answer by timeout_ms. */ + FIZZY_REMOTE_TIMED_OUT = 6, + /* The fizzy that was started exited with a failure before it listened. */ + FIZZY_REMOTE_START_FAILED = 7, + FIZZY_REMOTE_PROTOCOL = 8, + /* The connection ended (fizzy quit, or a bye): call fizzy_remote_reconnect. */ + FIZZY_REMOTE_CLOSED = 9, + FIZZY_REMOTE_OUT_OF_MEMORY = 10, + /* fizzy_remote_recv: the frame is kept, *len is the size it needs. */ + FIZZY_REMOTE_BUFFER_TOO_SMALL = 11, + FIZZY_REMOTE_INVALID_ARGUMENT = 12 +} fizzy_remote_status; + +/* fizzy_remote_options.flags */ +enum { + /* Connect to a running fizzy; never start one. */ + FIZZY_REMOTE_NO_START = 1 +}; + +/* Every string is NUL-terminated or NULL (the default, or the environment's). */ +typedef struct fizzy_remote_options { + uint32_t size; /* sizeof this struct */ + uint32_t timeout_ms; /* for open and reconnect, a start included; 0 is 15000 */ + uint32_t flags; + const char *host_name; /* what the host calls itself in its hello */ + const char *host_version; + /* The application to find, for an app built on fizzy: executable and pack id, macOS bundle + * name, bundle id. Fizzy's own when NULL. */ + const char *app_name; + const char *app_display_name; + const char *app_bundle_id; + /* Override FIZZY_EXE and FIZZY_PROFILE, and name the socket directly (for a host that cannot + * run fizzy to ask for it). */ + const char *exe; + const char *profile; + const char *socket_path; +} fizzy_remote_options; + +/* Find the installed fizzy, start it quietly (never taking focus) if nothing listens, connect + * and exchange hellos. On success *out is a handle for fizzy_remote_close. */ +int fizzy_remote_open(const fizzy_remote_options *options, fizzy_remote **out); + +/* Connect again after FIZZY_REMOTE_CLOSED, starting fizzy if it quit. Views are gone with the old + * stream: create them again. */ +int fizzy_remote_reconnect(fizzy_remote *r); + +/* Send one whole frame, as encoded for the wire. */ +int fizzy_remote_send(fizzy_remote *r, const uint8_t *frame, size_t len); + +/* The next frame from fizzy into buf, waiting up to timeout_ms (negative: forever). *len is its + * length, 0 with FIZZY_REMOTE_OK if the wait passed. A frame larger than cap is kept: + * FIZZY_REMOTE_BUFFER_TOO_SMALL with *len the size needed; call again with room. */ +int fizzy_remote_recv(fizzy_remote *r, uint8_t *buf, size_t cap, size_t *len, int32_t timeout_ms); + +/* The control socket in use, valid until the next call on r; NULL before a connect. */ +const char *fizzy_remote_socket_path(const fizzy_remote *r); + +/* A short English reason for a status, for a log. Never NULL. */ +const char *fizzy_remote_status_string(int status); + +/* The newest protocol version this library speaks. */ +uint32_t fizzy_remote_protocol_version(void); + +/* End the connection and free the handle. Fizzy keeps running. */ +void fizzy_remote_close(fizzy_remote *r); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/sdk/remote/root.zig b/sdk/remote/root.zig index 1205479c5..0214b791a 100644 --- a/sdk/remote/root.zig +++ b/sdk/remote/root.zig @@ -10,11 +10,13 @@ //! * `FrameReader` — cuts a byte stream into frames, in whatever chunks it arrives. //! * `Connection` — the rules of the conversation, with no bytes in it. //! * `Endpoint` — a `Connection` with the bytes around it, and a loopback `pump` for tests. +//! * `Client` — finds, quietly starts and connects to the installed fizzy; `capi.zig` is its C ABI. pub const message = @import("message.zig"); pub const codec = @import("codec.zig"); pub const FrameReader = @import("FrameReader.zig"); pub const Connection = @import("Connection.zig"); pub const Endpoint = @import("Endpoint.zig"); +pub const Client = @import("Client.zig"); pub const Message = message.Message; @@ -24,5 +26,8 @@ test { _ = FrameReader; _ = Connection; _ = Endpoint; + _ = Client; _ = @import("tests.zig"); + _ = @import("client_tests.zig"); + _ = @import("capi.zig"); }