a73x

src/cli/muxg.zig

Ref:   Size: 11.1 KiB   History

//! Native workspace entry; subsequent attachments are chosen in the picker.
const std = @import("std");
const native = @import("native");
const client = @import("client");
const term = @import("term");
const cliflags = @import("cliflags");
const sockpath = @import("sockpath");
const xdg = @import("xdg");

const proto = term.protocol;
const hosts = client.hosts;

const PointSize = struct {
    value: f64,
    pub fn parseCLI(text: []const u8) !PointSize {
        return .{ .value = native.font_options.parsePointSize(text) catch return error.Invalid };
    }
};
const Color = struct {
    value: u32,
    pub fn parseCLI(text: []const u8) !Color {
        return .{ .value = native.config.parseColor(text) catch return error.Invalid };
    }
};

const usage =
    \\usage: muxg [TARGET] [--forward LOCAL_PORT:REMOTE_PORT]... [--session NAME] [--sock PATH] [--via CMD] [--key PATH] [--theme NAME|PATH] [--background HEX] [--foreground HEX] [--cursor-color HEX] [--palette N=HEX] [--font-family FAMILY] [--font-size POINTS] [--font-px N]
    \\
    \\  TARGET       HOST (ssh handoff) or quic://HOST[:PORT]; none restores the saved workspace
    \\  --session    the session name (default: the daemon's default session)
    \\  --sock       a local daemon's socket path
    \\  --via        a command whose stdio is the daemon
    \\  --key        the QUIC key file (or MUX_KEY_FILE)
    \\  --forward    bind 127.0.0.1:LOCAL_PORT and reach 127.0.0.1:REMOTE_PORT on TARGET; repeatable
    \\  --font-px    font pixels at 100% display scale (default 16)
    \\  --font-family ordered font family; repeat for fallbacks (config: font-family)
    \\  --font-size  font size in points, 1–192 (config: font-size)
    \\  --theme      theme filename in config themes directory or absolute path
    \\  --background, --foreground, --cursor-color  explicit 6-digit colour
    \\  --palette    indexed colour override, N=RRGGBB; may be repeated
    \\  --help --version
    \\
;

const Arguments = struct {
    sock: ?[]const u8 = null,
    via: ?[]const u8 = null,
    key: ?[]const u8 = null,
    session: ?proto.SessionName = null,
    font_px: ?u16 = null,
    font_size: ?PointSize = null,
    theme: ?[]const u8 = null,
    background: ?Color = null,
    foreground: ?Color = null,
    cursor_color: ?Color = null,
    _palette_values: [256]?u32 = [_]?u32{null} ** 256,
    _font_families: std.ArrayListUnmanaged([:0]const u8) = .empty,
    _forwards: std.ArrayListUnmanaged(client.forward.Rule) = .empty,
    _argv_alloc: std.mem.Allocator = undefined,
    _font_family_oom: bool = false,
    _target: ?[]const u8 = null,
    _targets: usize = 0,

    pub fn positional(self: *Arguments, word: []const u8) bool {
        self._target = word;
        self._targets += 1;
        return true;
    }

    pub fn extra(self: *Arguments, rest: []const [:0]const u8) usize {
        if (std.mem.eql(u8, rest[0], "--font-family")) {
            if (rest.len < 2) return 0;
            self._font_families.append(self._argv_alloc, rest[1]) catch {
                self._font_family_oom = true;
            };
            return 2;
        }
        if (std.mem.eql(u8, rest[0], "--palette")) {
            if (rest.len < 2) return 0;
            const pair = native.config.parsePalette(rest[1]) catch return 0;
            self._palette_values[pair.index] = pair.color;
            return 2;
        }
        if (std.mem.eql(u8, rest[0], "--forward")) {
            if (rest.len < 2) return 0;
            const rule = client.forward.Rule.parse(rest[1]) catch return 0;
            self._forwards.append(self._argv_alloc, rule) catch {
                self._font_family_oom = true;
            };
            return 2;
        }
        return 0;
    }
};

comptime {
    cliflags.assertDocumented(Arguments, usage, &.{});
}

pub fn main() !u8 {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer if (gpa.deinit() == .leak) std.debug.print("muxg: LEAK: allocations outlived deinit\n", .{});
    const alloc = gpa.allocator();
    var argv_arena = std.heap.ArenaAllocator.init(alloc);
    defer argv_arena.deinit();
    const argv_alloc = argv_arena.allocator();
    const args = try std.process.argsAlloc(argv_alloc);
    if (try client.resolver.helper(alloc, args[1..])) |code| return code;

    var o: Arguments = .{ ._argv_alloc = argv_alloc };
    defer o._font_families.deinit(argv_alloc);
    defer o._forwards.deinit(argv_alloc);
    cliflags.parseStrict(Arguments, &o, args[1..]) catch |e| return cliflags.exitFor(e, usage, "muxg", std.fmt.comptimePrint("{s} ({s})", .{ @import("build_options").version, @tagName(@import("builtin").mode) }));
    if (o._font_family_oom) return error.OutOfMemory;
    if (o.font_px != null and o.font_size != null) {
        std.debug.print("muxg: --font-px and --font-size are ambiguous; choose one\n", .{});
        return 2;
    }
    var settings: native.config.Settings = .{};
    defer settings.deinit(argv_alloc);
    var config_line: usize = 1;
    const config_path = xdg.pathFrom(argv_alloc, std.posix.getenv("XDG_CONFIG_HOME"), std.posix.getenv("HOME"), ".config", "config") catch |err| switch (err) {
        error.NoHome => null,
        else => return err,
    };
    if (config_path) |path| settings = native.config.load(argv_alloc, path, &config_line) catch |err| {
        const reason = switch (err) {
            error.UnknownKey => "unknown key; supported keys: font-family, font-size, theme, foreground, background, cursor-color, palette",
            error.InvalidSyntax => "expected one key = value per line; only font-family may repeat",
            error.InvalidValue => "font-size must be a finite number between 1 and 192 points",
            error.InvalidColor => "colour must be exactly six hexadecimal digits",
            error.InvalidPalette => "palette must be N=RRGGBB with N between 0 and 255",
            error.InvalidThemeName => "theme must name a file or an absolute path",
            error.MissingFamily => "font-family must name an installed monospace family",
            else => @errorName(err),
        };
        std.debug.print("muxg: config {s}:{d}: {s}\n", .{ path, config_line, reason });
        return 2;
    };
    var explicit: native.theme.Overrides = .{
        .foreground = if (o.foreground) |v| v.value else settings.foreground,
        .background = if (o.background) |v| v.value else settings.background,
        .cursor = if (o.cursor_color) |v| v.value else settings.cursor_color,
    };
    for (settings.palette, 0..) |v, i| explicit.palette[i] = v;
    for (o._palette_values, 0..) |v, i| {
        if (v) |color| explicit.palette[i] = color;
    }
    var selected: native.theme.Overrides = .{};
    const selection = o.theme orelse if (settings.theme) |v| v else null;
    if (selection) |name| {
        const theme_path = resolveThemePath(argv_alloc, config_path, name) catch |err| {
            if (o.theme == null and config_path != null) {
                std.debug.print("muxg: config {s}:{d}: invalid theme '{s}': {s}\n", .{ config_path.?, settings.theme_line, name, @errorName(err) });
            } else std.debug.print("muxg: invalid theme '{s}': {s}\n", .{ name, @errorName(err) });
            return 2;
        };
        var theme_line: usize = 1;
        var warning_ctx = WarningContext{ .path = theme_path };
        selected = native.theme.load(argv_alloc, theme_path, &theme_line, &warning_ctx, themeWarning) catch |err| {
            std.debug.print("muxg: theme {s}:{d}: {s}\n", .{ theme_path, theme_line, if (err == error.InvalidValue) "invalid colour" else if (err == error.InvalidSyntax) "expected one key = value per line" else @errorName(err) });
            return 2;
        };
    }
    const named: usize = @as(usize, @intFromBool(o.sock != null)) + @intFromBool(o.via != null) + o._targets;
    if (named > 1) {
        std.debug.print("muxg: name one transport: HOST, --sock, --via or quic://\n{s}", .{usage});
        return 2;
    }
    if (o._forwards.items.len != 0 and named == 0) {
        std.debug.print("muxg: --forward requires an explicit TARGET, --sock, or --via\n", .{});
        return 2;
    }
    const cli_points: ?f64 = if (o.font_size) |points| points.value else null;
    const font_points = if (o.font_px != null) null else cli_points orelse settings.size_points;
    const font_px = o.font_px orelse 16;
    if (font_px == 0 or font_px > 256) {
        std.debug.print("muxg: --font-px must be between 1 and 256\n", .{});
        return 2;
    }
    const session = if (o.session) |n| n.name else "";
    const key = std.posix.getenv("MUX_KEY_FILE");
    const font_families: []const [:0]const u8 = if (o._font_families.items.len != 0)
        o._font_families.items
    else if (settings.families.len != 0)
        settings.families
    else
        native.font.default_families;

    const temporary = named != 0 or o.session != null;
    const local_path: ?[]const u8 = if (o.sock) |path| path else if (o._target != null or o.via != null) null else if (temporary) (try sockpath.defaultOrExplain(argv_alloc, "muxg") orelse return 1) else sockpath.defaultSockPath(argv_alloc) catch null;
    const local: ?client.Target = if (local_path) |path| .{ .sock = path } else null;
    const target: ?client.Target = if (!temporary) null else if (o.via) |cmd| .{ .via = cmd } else if (o._target) |word| resolve(argv_alloc, word, o.key orelse key) catch |err| {
        std.debug.print("muxg: bad target: {s}\n", .{@errorName(err)});
        return 2;
    } else local;
    return native.run(alloc, .{
        .target = target,
        .local_target = local,
        .state_path = if (temporary) null else try xdg.statePath(argv_alloc, "native-workspace.json"),
        .key_path = o.key orelse key,
        .session = session,
        .font_px = font_px,
        .font_families = font_families,
        .font_points = font_points,
        .appearance = native.theme.merge(native.theme.legacy, selected, explicit),
        .test_fifo = std.posix.getenv("MUXG_TEST_FIFO"),
        .forwards = o._forwards.items,
    }) catch |err| {
        std.debug.print("muxg: {s}\n", .{if (err == error.WorkspaceAlreadyOpen) "the saved workspace is already open" else @errorName(err)});
        return 2;
    };
}

const WarningContext = struct { path: []const u8 };
fn themeWarning(ctx: ?*anyopaque, line: usize, key: []const u8) void {
    const info: *WarningContext = @ptrCast(@alignCast(ctx.?));
    std.debug.print("{s}:{d}: unsupported theme key {s}; ignored\n", .{ info.path, line, key });
}

fn resolveThemePath(alloc: std.mem.Allocator, config_path: ?[]const u8, name: []const u8) ![]const u8 {
    if (name.len == 0) return error.InvalidThemeName;
    if (std.fs.path.isAbsolute(name)) return alloc.dupe(u8, name);
    if (std.mem.indexOfAny(u8, name, "/\\") != null or std.mem.eql(u8, name, ".") or std.mem.eql(u8, name, "..")) return error.InvalidThemeName;
    const cfg = config_path orelse return error.NoHome;
    const dir = std.fs.path.dirname(cfg) orelse return error.InvalidThemeName;
    return std.fmt.allocPrint(alloc, "{s}/themes/{s}", .{ dir, name });
}
fn resolve(alloc: std.mem.Allocator, word: []const u8, key: ?[]const u8) !client.Target {
    var target = try client.Target.fromSpec(alloc, try hosts.parse(word), key, client.quic_idle_ms_default, false);
    if (target == .hand) target.hand.narrate = true;
    return target;
}