From eca0799290bc335b38862267c1fba54a2add7edc Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 00:26:32 +0300 Subject: [PATCH 01/86] feat(setup): NSIS-free install mechanics (file place + shortcuts + PATH) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First piece of the custom app-style installer (replacing the classic NSIS wizard). internal/setup mirrors exactly what NSIS did — lay the bundled binaries into %LOCALAPPDATA%\Programs\Clawtool, create Start-menu + Desktop shortcuts, add the dir to the user PATH, and register an Add/Remove entry with a self-contained uninstall.ps1 — but as a library the clawtool-setup app drives with a modern UI. Windows OS integration via PowerShell (no fragile COM/syscall). Cross-platform-compilable; Windows branches no-op elsewhere. --- internal/setup/setup.go | 188 +++++++++++++++++++++++++++++++++++ internal/setup/setup_test.go | 38 +++++++ 2 files changed, 226 insertions(+) create mode 100644 internal/setup/setup.go create mode 100644 internal/setup/setup_test.go diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..41a60f7 --- /dev/null +++ b/internal/setup/setup.go @@ -0,0 +1,188 @@ +// Package setup performs a custom, NSIS-free install of clawtool on Windows. +// +// It mirrors exactly what the old NSIS wizard did — place the bundled +// binaries under %LOCALAPPDATA%\Programs\Clawtool, create Start-menu + +// Desktop shortcuts, add the install dir to the user PATH, and register an +// Add/Remove-Programs entry with a working uninstaller — but driven by the +// clawtool-setup app's modern UI instead of a classic Next/Next wizard. +// +// Windows OS integration (shortcuts, PATH, registry) is done via PowerShell +// rather than COM/syscall: it's the dependency-free, well-trodden path and +// keeps this package simple. Everything compiles on every OS; the Windows +// branches no-op elsewhere (macOS ships a .dmg, not this installer). +package setup + +import ( + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const productName = "Clawtool" + +// PayloadBinaries are the files the setup app embeds and lays into the +// install dir: the GUI app, the headless CLI/daemon, and the updater. +var PayloadBinaries = []string{"Clawtool.exe", "clawtool.exe", "ClawtoolUpdate.exe"} + +// Progress reports one install step to the UI. detail is optional context. +type Progress func(label, detail string) + +func noop(string, string) {} + +// InstallDir returns %LOCALAPPDATA%\Programs\Clawtool — the per-user location +// the NSIS installer used, so `clawtool upgrade` keeps swapping in place with +// no UAC prompt. On non-Windows it returns a sane per-user dir (unused in +// practice; macOS ships a .dmg). +func InstallDir() string { + if runtime.GOOS == "windows" { + base := os.Getenv("LOCALAPPDATA") + if base == "" { + base = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local") + } + return filepath.Join(base, "Programs", productName) + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "share", strings.ToLower(productName)) +} + +// Install lays clawtool down from the embedded payload. Idempotent: a second +// run overwrites the binaries and refreshes shortcuts / PATH / registry. +// Returns the install dir. +func Install(payload fs.FS, version string, progress Progress) (string, error) { + if progress == nil { + progress = noop + } + dir := InstallDir() + + progress("Closing any running clawtool", "") + stopRunning(dir) + + progress("Preparing "+dir, "") + if err := os.MkdirAll(dir, 0o755); err != nil { + return dir, fmt.Errorf("create install dir: %w", err) + } + + for _, name := range PayloadBinaries { + progress("Installing "+name, "") + if err := copyFromFS(payload, name, filepath.Join(dir, name)); err != nil { + return dir, fmt.Errorf("write %s: %w", name, err) + } + } + + if runtime.GOOS == "windows" { + progress("Creating shortcuts", "") + if err := windowsShortcuts(dir); err != nil { + return dir, fmt.Errorf("shortcuts: %w", err) + } + progress("Adding clawtool to your PATH", "") + if err := windowsAddToPath(dir); err != nil { + return dir, fmt.Errorf("PATH: %w", err) + } + progress("Registering uninstaller", "") + if err := windowsRegisterUninstall(dir, version); err != nil { + return dir, fmt.Errorf("register uninstaller: %w", err) + } + } + return dir, nil +} + +// LaunchInstalled starts the freshly-installed app in installer mode (which +// runs the one-time initialize flow), detached, so setup can exit. +func LaunchInstalled() error { + cmd := exec.Command(filepath.Join(InstallDir(), "Clawtool.exe"), "--installer") + return cmd.Start() +} + +func copyFromFS(src fs.FS, name, dst string) error { + data, err := fs.ReadFile(src, name) + if err != nil { + return err + } + return os.WriteFile(dst, data, 0o755) +} + +func stopRunning(dir string) { + if runtime.GOOS != "windows" { + return + } + // Best-effort: stop the daemon and kill running images so the .exe files + // aren't locked while we overwrite them. + _ = exec.Command(filepath.Join(dir, "clawtool.exe"), "daemon", "stop").Run() + for _, img := range PayloadBinaries { + _ = exec.Command("taskkill", "/F", "/IM", img).Run() + } +} + +// windowsShortcuts writes Start-menu + Desktop .lnk files via WScript.Shell. +func windowsShortcuts(dir string) error { + target := filepath.Join(dir, "Clawtool.exe") + ps := fmt.Sprintf(` +$ws = New-Object -ComObject WScript.Shell +foreach ($d in @($ws.SpecialFolders('Programs'), $ws.SpecialFolders('Desktop'))) { + $lnk = $ws.CreateShortcut((Join-Path $d 'Clawtool.lnk')) + $lnk.TargetPath = %q + $lnk.WorkingDirectory = %q + $lnk.IconLocation = %q + $lnk.Save() +}`, target, dir, target) + return powershell(ps) +} + +// windowsAddToPath appends the install dir to the user PATH (idempotent). +func windowsAddToPath(dir string) error { + ps := fmt.Sprintf(` +$p = [Environment]::GetEnvironmentVariable('Path','User') +if (-not ($p -split ';' | Where-Object { $_ -eq %q })) { + if ($p -and -not $p.EndsWith(';')) { $p += ';' } + [Environment]::SetEnvironmentVariable('Path', $p + %q, 'User') +}`, dir, dir) + return powershell(ps) +} + +// windowsRegisterUninstall writes the Add/Remove-Programs entry and drops a +// self-contained uninstall.ps1 (removes the dir, PATH entry, shortcuts, and +// the registry key) that UninstallString points at — so uninstall works +// without needing the app to handle a flag. +func windowsRegisterUninstall(dir, version string) error { + key := `HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Clawtool` + icon := filepath.Join(dir, "Clawtool.exe") + uninstallScript := filepath.Join(dir, "uninstall.ps1") + + script := fmt.Sprintf(` +foreach ($img in 'Clawtool.exe','clawtool.exe','ClawtoolUpdate.exe') { taskkill /F /IM $img 2>$null } +$p = ([Environment]::GetEnvironmentVariable('Path','User') -split ';' | Where-Object { $_ -and $_ -ne %q }) -join ';' +[Environment]::SetEnvironmentVariable('Path', $p, 'User') +$ws = New-Object -ComObject WScript.Shell +foreach ($d in @($ws.SpecialFolders('Programs'), $ws.SpecialFolders('Desktop'))) { Remove-Item (Join-Path $d 'Clawtool.lnk') -ErrorAction SilentlyContinue } +Remove-Item -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Clawtool' -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -Path %q -Recurse -Force -ErrorAction SilentlyContinue`, dir, dir) + if err := os.WriteFile(uninstallScript, []byte(script), 0o644); err != nil { + return err + } + + uninstallCmd := fmt.Sprintf(`powershell -NoProfile -ExecutionPolicy Bypass -File "%s"`, uninstallScript) + reg := fmt.Sprintf(` +New-Item -Path %q -Force | Out-Null +Set-ItemProperty -Path %q -Name 'DisplayName' -Value 'Clawtool' +Set-ItemProperty -Path %q -Name 'DisplayVersion' -Value %q +Set-ItemProperty -Path %q -Name 'Publisher' -Value 'Cogitave' +Set-ItemProperty -Path %q -Name 'DisplayIcon' -Value %q +Set-ItemProperty -Path %q -Name 'InstallLocation' -Value %q +Set-ItemProperty -Path %q -Name 'UninstallString' -Value %q +Set-ItemProperty -Path %q -Name 'NoModify' -Value 1 -Type DWord +Set-ItemProperty -Path %q -Name 'NoRepair' -Value 1 -Type DWord`, + key, key, key, version, key, key, icon, key, dir, key, uninstallCmd, key, key) + return powershell(reg) +} + +func powershell(script string) error { + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go new file mode 100644 index 0000000..38b2f1d --- /dev/null +++ b/internal/setup/setup_test.go @@ -0,0 +1,38 @@ +package setup + +import ( + "os" + "path/filepath" + "testing" + "testing/fstest" +) + +func TestInstallDir_NonEmpty(t *testing.T) { + if InstallDir() == "" { + t.Fatal("InstallDir must not be empty") + } +} + +func TestCopyFromFS(t *testing.T) { + payload := fstest.MapFS{"clawtool.exe": {Data: []byte("BINARY")}} + dst := filepath.Join(t.TempDir(), "clawtool.exe") + if err := copyFromFS(payload, "clawtool.exe", dst); err != nil { + t.Fatalf("copy: %v", err) + } + got, err := os.ReadFile(dst) + if err != nil || string(got) != "BINARY" { + t.Fatalf("copied file wrong: %q err=%v", got, err) + } +} + +func TestPayloadBinariesIncludesAll(t *testing.T) { + want := map[string]bool{"Clawtool.exe": false, "clawtool.exe": false, "ClawtoolUpdate.exe": false} + for _, b := range PayloadBinaries { + want[b] = true + } + for name, found := range want { + if !found { + t.Errorf("PayloadBinaries missing %s", name) + } + } +} From a31f7c592fd11abff52af9662dcc4b4da8ca48ba Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 00:31:31 +0300 Subject: [PATCH 02/86] refactor(setup): move install mechanics into the installer module The mechanics landed in the main module's internal/setup (which is actually the recipe/onboard-runner package) by mistake. Move them to a self-contained cmd/clawtool-installer/install package: pure os/exec + PowerShell, no main-module deps, so the Wails installer build stays decoupled (same reason clawtool-installer is its own module). internal/setup restored untouched. --- .../setup/setup.go => cmd/clawtool-installer/install/install.go | 2 +- .../clawtool-installer/install/install_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename internal/setup/setup.go => cmd/clawtool-installer/install/install.go (99%) rename internal/setup/setup_test.go => cmd/clawtool-installer/install/install_test.go (98%) diff --git a/internal/setup/setup.go b/cmd/clawtool-installer/install/install.go similarity index 99% rename from internal/setup/setup.go rename to cmd/clawtool-installer/install/install.go index 41a60f7..400f661 100644 --- a/internal/setup/setup.go +++ b/cmd/clawtool-installer/install/install.go @@ -10,7 +10,7 @@ // rather than COM/syscall: it's the dependency-free, well-trodden path and // keeps this package simple. Everything compiles on every OS; the Windows // branches no-op elsewhere (macOS ships a .dmg, not this installer). -package setup +package install import ( "fmt" diff --git a/internal/setup/setup_test.go b/cmd/clawtool-installer/install/install_test.go similarity index 98% rename from internal/setup/setup_test.go rename to cmd/clawtool-installer/install/install_test.go index 38b2f1d..a14a50d 100644 --- a/internal/setup/setup_test.go +++ b/cmd/clawtool-installer/install/install_test.go @@ -1,4 +1,4 @@ -package setup +package install import ( "os" From d86b5ab79e37e2536926750088b92244a00691f8 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 00:42:18 +0300 Subject: [PATCH 03/86] feat(setup): self-installing setup mode for the desktop app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup build embeds the payload (Clawtool.exe, clawtool.exe, ClawtoolUpdate.exe) via //go:embed; when present, the binary boots in "setup" mode and self-installs to %LOCALAPPDATA%\Programs\Clawtool — no classic wizard — then launches the installed app. Progress streams to a dedicated setup phase in the UI as "setup:step" events, with monotonic progress that tolerates async event delivery. --- cmd/clawtool-installer/app.go | 33 ++++++++++- .../frontend/dist/index.html | 58 ++++++++++++++++++- cmd/clawtool-installer/main.go | 6 ++ cmd/clawtool-installer/payload.go | 27 +++++++++ cmd/clawtool-installer/payload/.gitkeep | 2 + 5 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 cmd/clawtool-installer/payload.go create mode 100644 cmd/clawtool-installer/payload/.gitkeep diff --git a/cmd/clawtool-installer/app.go b/cmd/clawtool-installer/app.go index 6435783..3503f2b 100644 --- a/cmd/clawtool-installer/app.go +++ b/cmd/clawtool-installer/app.go @@ -18,6 +18,7 @@ import ( "sync" "time" + "github.com/cogitave/clawtool/cmd/clawtool-installer/install" "github.com/energye/systray" "github.com/wailsapp/wails/v2/pkg/options" wruntime "github.com/wailsapp/wails/v2/pkg/runtime" @@ -51,10 +52,38 @@ func NewApp() *App { return &App{} } // initializes — on launch it only checks for updates, then shows the UI. var desktopMode = "app" -// Mode reports the running role ("app" | "installer") so the frontend's -// boot() can pick the install flow vs the app flow. +// Mode reports the running role ("app" | "installer" | "setup") so the +// frontend's boot() can pick the right flow. func (a *App) Mode() string { return desktopMode } +// setupVersion is stamped by CI (ldflags) into the ClawtoolSetup build so the +// Add/Remove-Programs entry shows the right version; "dev" for local builds. +var setupVersion = "dev" + +// RunSetup is called by the frontend in setup mode: lay the embedded payload +// down (install dir + shortcuts + PATH + uninstaller), then launch the +// freshly-installed app. Progress streams as "setup:step" events. The install +// already succeeded by the time we try to launch, so a launch failure is +// non-fatal. +func (a *App) RunSetup() string { + root, err := payloadRoot() + if err != nil { + return jsonErr("install payload unavailable: " + err.Error()) + } + dir, err := install.Install(root, setupVersion, func(label, detail string) { + wruntime.EventsEmit(a.ctx, "setup:step", map[string]string{"label": label, "detail": detail}) + }) + if err != nil { + return jsonErr(err.Error()) + } + _ = install.LaunchInstalled() + b, _ := json.Marshal(struct { + OK bool `json:"ok"` + Dir string `json:"dir"` + }{OK: true, Dir: dir}) + return string(b) +} + // startup captures the Wails runtime context and starts the system-tray // presence so the operator can see clawtool is running (and reopen / // quit it). diff --git a/cmd/clawtool-installer/frontend/dist/index.html b/cmd/clawtool-installer/frontend/dist/index.html index e259eec..5213e74 100644 --- a/cmd/clawtool-installer/frontend/dist/index.html +++ b/cmd/clawtool-installer/frontend/dist/index.html @@ -208,6 +208,15 @@

Initializing clawtool

+ +
+

Installing clawtool

+
Setting up on this device…
+
0%
+
    + +
    +
    - -
    -

    Installing clawtool

    -
    Setting up on this device…
    -
    0%
    -
      - + +
      +
      + + +
      +
      +

      Install

      +

      +
      +
      + + The command, added to your PATH. +
      +
      + + A desktop app that runs in your tray as the gateway. +
      +
      + + Quiet self-updates — no admin prompts. +
      +
      +
      +
      + + +
      +
      + + +
      +

      Installing

      step 2 / 3
      +
      +
      Starting…0%
      +
      +
      install log
      +
      +
      +
      + + +
      +
      + + installed +
      +
      +
      + +
      +

      is ready

      +

      Installed to . Open a new terminal to use the command.

      +
      + + +
      +
      @@ -343,48 +442,64 @@

      Updates

      call("Install"); } - // ── Setup (ClawtoolSetup self-install) ────────────────────── - // The setup build embeds the payload (Clawtool.exe + clawtool.exe + - // updater). RunSetup lays it on disk, wires shortcuts/PATH, then - // launches the installed app. Steps stream as "setup:step" events; - // there's no fixed count, so progress eases toward 95% per step and - // snaps to 100% on completion. - let setupSteps = 0, setupPct = 0, setupFinished = false; - // Monotonic — Wails delivers events asynchronously, so a step can land - // after RunSetup resolved; never let progress run backwards. - function setSetupProgress(p) { - setupPct = Math.max(setupPct, Math.min(p, 100)); - $("setupFill").style.width = setupPct + "%"; - $("setupPct").textContent = setupPct + "%"; + // ── Custom stepped installer (Welcome → Installing → Done) ── + // The setup build embeds the payload; RunSetup lays it on disk and + // streams "setup:step" events. User-driven: Welcome [Install] → live + // install log → Done [Open]. Product identity comes from App.Brand(), + // so the UI never hardcodes the name. + let BRAND = { name: "clawtool", cli: "clawtool", tagline: "", installDir: "", version: "" }; + async function loadBrand() { const b = parse(await call("Brand"), null); if (b && b.name) BRAND = b; } + function applyBrand() { + document.querySelectorAll("[data-brand-name]").forEach((e) => (e.textContent = BRAND.name)); + document.querySelectorAll("[data-brand-cli]").forEach((e) => (e.textContent = BRAND.cli)); + document.querySelectorAll("[data-brand-tagline]").forEach((e) => (e.textContent = BRAND.tagline || "")); + document.querySelectorAll("[data-brand-mark]").forEach((e) => (e.innerHTML = esc(BRAND.name) + '.')); + if (BRAND.installDir) { $("w-loc").textContent = BRAND.installDir; $("d-dir").textContent = BRAND.installDir; } + if (BRAND.version) $("w-ver").textContent = "v" + BRAND.version; + document.title = BRAND.name; } - function addSetupStep(ev) { - if (setupFinished) return; - const ul = $("setup-log"); - const li = document.createElement("li"); - li.innerHTML = '' + CHECK + '' + esc((ev && ev.label) || "") + "" + (ev && ev.detail ? '' + esc(ev.detail) + "" : ""); - ul.appendChild(li); - while (ul.children.length > 6) ul.removeChild(ul.firstChild); - if (ev && ev.label) $("setupSub").textContent = ev.label + "…"; - setupSteps++; - setSetupProgress(Math.min(95, 10 + setupSteps * 14)); + + // Monotonic progress — Wails delivers events asynchronously, so a step + // can land after RunSetup resolved; never let progress run backwards. + let iPct = 0, setupFinished = false, stepN = 0; + function setIPct(p) { iPct = Math.max(iPct, Math.min(p, 100)); $("i-fill").style.width = iPct + "%"; $("i-pct").textContent = Math.round(iPct) + "%"; } + function clock() { const d = new Date(); return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":"); } + function logLine(html, cls) { + const feed = $("i-log"); + const line = document.createElement("div"); + line.className = "su-lline" + (cls ? " " + cls : ""); + line.innerHTML = '' + clock() + '' + html + ""; + feed.appendChild(line); feed.scrollTop = feed.scrollHeight; + } + function onSetupStep(ev) { + if (setupFinished || !ev || !ev.label) return; + $("i-now").textContent = ev.label + "…"; + logLine(esc(ev.label) + (ev.detail ? ' ' + esc(ev.detail) + "" : ""), "ok"); + stepN++; setIPct(8 + Math.min(88, stepN * 9)); } - async function runSetup() { - showPhase("setup"); - setSetupProgress(5); - if (window.runtime && window.runtime.EventsOn) window.runtime.EventsOn("setup:step", addSetupStep); + async function startInstall() { + showPhase("installing"); setIPct(4); + logLine('setup beginning install of ' + esc(BRAND.name), "ok"); + if (window.runtime && window.runtime.EventsOn) window.runtime.EventsOn("setup:step", onSetupStep); const r = parse(await call("RunSetup"), { ok: false }); const ok = !!(r && r.ok); - setupFinished = true; - setSetupProgress(100); - $("setupTitle").textContent = ok ? "clawtool is installed" : "Install needs attention"; - $("setupSub").textContent = ok ? "Opening clawtool…" : "Something didn't finish."; - const d = $("setupDone"); d.style.display = "block"; - d.innerHTML = ok - ? "Installed to " + esc((r && r.dir) || "") + " — launching now. You can close this window." - : esc((r && r.error) || "Setup could not complete. Please try running the installer again."); - // The installed Clawtool.exe is already launching; close this - // bootstrap setup window so only the real app remains. - if (ok) setTimeout(() => call("Quit"), 1600); + setupFinished = true; setIPct(100); + if (ok) { + $("i-now").textContent = "Done"; + logLine('setup install complete', "done"); + if (r.dir) $("d-dir").textContent = r.dir; + await sleep(550); showPhase("done"); + } else { + $("i-now").textContent = "Failed"; + logLine('setup ' + esc((r && r.error) || "install failed"), "warn"); + } + } + function setupWelcome() { + applyBrand(); + $("w-install").onclick = startInstall; + $("d-open").onclick = async () => { await call("OpenInstalled"); setTimeout(() => call("Quit"), 600); }; + $("d-finish").onclick = () => call("Quit"); + showPhase("welcome"); } // ── Network view ──────────────────────────────────────────── @@ -601,9 +716,9 @@

      Updates

      async function boot() { for (let i = 0; i < 40 && !app(); i++) await sleep(50); const mode = await call("Mode"); - // Setup build: self-install the embedded payload, then launch the - // installed app — no update-check (this IS the fresh install). - if (mode === "setup") { return runSetup(); } + // Setup build: show the stepped custom installer (Welcome → Installing + // → Done). No update-check — this IS the fresh install. + if (mode === "setup") { await loadBrand(); return setupWelcome(); } showPhase("updater"); $("upd-splash-status").textContent = "Checking for updates…"; lastCheck = parse(await call("CheckUpdate"), null); diff --git a/cmd/clawtool-installer/install/install.go b/cmd/clawtool-installer/install/install.go index 400f661..b79cd34 100644 --- a/cmd/clawtool-installer/install/install.go +++ b/cmd/clawtool-installer/install/install.go @@ -1,15 +1,16 @@ -// Package setup performs a custom, NSIS-free install of clawtool on Windows. +// Package install performs a custom, NSIS-free install on Windows. // // It mirrors exactly what the old NSIS wizard did — place the bundled -// binaries under %LOCALAPPDATA%\Programs\Clawtool, create Start-menu + +// binaries under %LOCALAPPDATA%\Programs\, create Start-menu + // Desktop shortcuts, add the install dir to the user PATH, and register an // Add/Remove-Programs entry with a working uninstaller — but driven by the -// clawtool-setup app's modern UI instead of a classic Next/Next wizard. +// setup app's modern, stepped UI instead of a classic Next/Next wizard. // -// Windows OS integration (shortcuts, PATH, registry) is done via PowerShell -// rather than COM/syscall: it's the dependency-free, well-trodden path and -// keeps this package simple. Everything compiles on every OS; the Windows -// branches no-op elsewhere (macOS ships a .dmg, not this installer). +// All product names come from the brand package (single source of truth), so +// a rename never touches this file. Windows OS integration (shortcuts, PATH, +// registry) is done via PowerShell rather than COM/syscall: it's the +// dependency-free, well-trodden path. Everything compiles on every OS; the +// Windows branches no-op elsewhere (macOS ships a .dmg, not this installer). package install import ( @@ -20,20 +21,23 @@ import ( "path/filepath" "runtime" "strings" -) -const productName = "Clawtool" + "github.com/cogitave/clawtool/cmd/clawtool-installer/brand" +) // PayloadBinaries are the files the setup app embeds and lays into the // install dir: the GUI app, the headless CLI/daemon, and the updater. -var PayloadBinaries = []string{"Clawtool.exe", "clawtool.exe", "ClawtoolUpdate.exe"} +func PayloadBinaries() []string { + return []string{brand.AppExe(), brand.CLIExe(), brand.UpdaterExe()} +} -// Progress reports one install step to the UI. detail is optional context. +// Progress reports one install step to the UI. detail is optional context +// (a path, a size, a short note) shown dimmed next to the label. type Progress func(label, detail string) func noop(string, string) {} -// InstallDir returns %LOCALAPPDATA%\Programs\Clawtool — the per-user location +// InstallDir returns %LOCALAPPDATA%\Programs\ — the per-user location // the NSIS installer used, so `clawtool upgrade` keeps swapping in place with // no UAC prompt. On non-Windows it returns a sane per-user dir (unused in // practice; macOS ships a .dmg). @@ -43,46 +47,50 @@ func InstallDir() string { if base == "" { base = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Local") } - return filepath.Join(base, "Programs", productName) + return filepath.Join(base, "Programs", brand.Display) } home, _ := os.UserHomeDir() - return filepath.Join(home, ".local", "share", strings.ToLower(productName)) + return filepath.Join(home, ".local", "share", strings.ToLower(brand.Display)) } -// Install lays clawtool down from the embedded payload. Idempotent: a second -// run overwrites the binaries and refreshes shortcuts / PATH / registry. -// Returns the install dir. +// Install lays the product down from the embedded payload. Idempotent: a +// second run overwrites the binaries and refreshes shortcuts / PATH / +// registry. Returns the install dir. It does NOT launch the app — the caller +// (the Done step) decides when to open it. func Install(payload fs.FS, version string, progress Progress) (string, error) { if progress == nil { progress = noop } dir := InstallDir() - progress("Closing any running clawtool", "") + progress("Closing any running "+brand.CLI, "daemon stop · taskkill") stopRunning(dir) - progress("Preparing "+dir, "") + progress("Preparing install directory", dir) if err := os.MkdirAll(dir, 0o755); err != nil { return dir, fmt.Errorf("create install dir: %w", err) } - for _, name := range PayloadBinaries { - progress("Installing "+name, "") - if err := copyFromFS(payload, name, filepath.Join(dir, name)); err != nil { + for _, name := range PayloadBinaries() { + dst := filepath.Join(dir, name) + if err := copyFromFS(payload, name, dst); err != nil { return dir, fmt.Errorf("write %s: %w", name, err) } + // Emit once, after the write, with the on-disk size — the log shows + // "Writing clawtool.exe 31.2 MB". + progress("Writing "+name, humanSize(dst)) } if runtime.GOOS == "windows" { - progress("Creating shortcuts", "") + progress("Creating shortcuts", "Start menu · Desktop") if err := windowsShortcuts(dir); err != nil { return dir, fmt.Errorf("shortcuts: %w", err) } - progress("Adding clawtool to your PATH", "") + progress("Adding "+brand.CLI+" to PATH", "user environment") if err := windowsAddToPath(dir); err != nil { return dir, fmt.Errorf("PATH: %w", err) } - progress("Registering uninstaller", "") + progress("Registering uninstaller", "Apps & features") if err := windowsRegisterUninstall(dir, version); err != nil { return dir, fmt.Errorf("register uninstaller: %w", err) } @@ -93,7 +101,7 @@ func Install(payload fs.FS, version string, progress Progress) (string, error) { // LaunchInstalled starts the freshly-installed app in installer mode (which // runs the one-time initialize flow), detached, so setup can exit. func LaunchInstalled() error { - cmd := exec.Command(filepath.Join(InstallDir(), "Clawtool.exe"), "--installer") + cmd := exec.Command(filepath.Join(InstallDir(), brand.AppExe()), "--installer") return cmd.Start() } @@ -105,30 +113,48 @@ func copyFromFS(src fs.FS, name, dst string) error { return os.WriteFile(dst, data, 0o755) } +// humanSize returns the file's size like "31.2 MB" (best-effort; "" on error). +func humanSize(path string) string { + fi, err := os.Stat(path) + if err != nil { + return "" + } + const u = 1024.0 + b := float64(fi.Size()) + switch { + case b >= u*u: + return fmt.Sprintf("%.1f MB", b/(u*u)) + case b >= u: + return fmt.Sprintf("%.0f KB", b/u) + default: + return fmt.Sprintf("%d B", fi.Size()) + } +} + func stopRunning(dir string) { if runtime.GOOS != "windows" { return } // Best-effort: stop the daemon and kill running images so the .exe files // aren't locked while we overwrite them. - _ = exec.Command(filepath.Join(dir, "clawtool.exe"), "daemon", "stop").Run() - for _, img := range PayloadBinaries { + _ = exec.Command(filepath.Join(dir, brand.CLIExe()), "daemon", "stop").Run() + for _, img := range PayloadBinaries() { _ = exec.Command("taskkill", "/F", "/IM", img).Run() } } // windowsShortcuts writes Start-menu + Desktop .lnk files via WScript.Shell. func windowsShortcuts(dir string) error { - target := filepath.Join(dir, "Clawtool.exe") + target := filepath.Join(dir, brand.AppExe()) ps := fmt.Sprintf(` $ws = New-Object -ComObject WScript.Shell foreach ($d in @($ws.SpecialFolders('Programs'), $ws.SpecialFolders('Desktop'))) { - $lnk = $ws.CreateShortcut((Join-Path $d 'Clawtool.lnk')) + $lnk = $ws.CreateShortcut((Join-Path $d %q)) $lnk.TargetPath = %q $lnk.WorkingDirectory = %q $lnk.IconLocation = %q $lnk.Save() -}`, target, dir, target) +}`, brand.ShortcutName(), target, dir, target) return powershell(ps) } @@ -148,18 +174,20 @@ if (-not ($p -split ';' | Where-Object { $_ -eq %q })) { // the registry key) that UninstallString points at — so uninstall works // without needing the app to handle a flag. func windowsRegisterUninstall(dir, version string) error { - key := `HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Clawtool` - icon := filepath.Join(dir, "Clawtool.exe") + key := `HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\` + brand.Display + icon := filepath.Join(dir, brand.AppExe()) uninstallScript := filepath.Join(dir, "uninstall.ps1") + bins := "'" + strings.Join(PayloadBinaries(), "','") + "'" script := fmt.Sprintf(` -foreach ($img in 'Clawtool.exe','clawtool.exe','ClawtoolUpdate.exe') { taskkill /F /IM $img 2>$null } +foreach ($img in %s) { taskkill /F /IM $img 2>$null } $p = ([Environment]::GetEnvironmentVariable('Path','User') -split ';' | Where-Object { $_ -and $_ -ne %q }) -join ';' [Environment]::SetEnvironmentVariable('Path', $p, 'User') $ws = New-Object -ComObject WScript.Shell -foreach ($d in @($ws.SpecialFolders('Programs'), $ws.SpecialFolders('Desktop'))) { Remove-Item (Join-Path $d 'Clawtool.lnk') -ErrorAction SilentlyContinue } -Remove-Item -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Clawtool' -Recurse -Force -ErrorAction SilentlyContinue -Remove-Item -Path %q -Recurse -Force -ErrorAction SilentlyContinue`, dir, dir) +foreach ($d in @($ws.SpecialFolders('Programs'), $ws.SpecialFolders('Desktop'))) { Remove-Item (Join-Path $d %q) -ErrorAction SilentlyContinue } +Remove-Item -Path %q -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -Path %q -Recurse -Force -ErrorAction SilentlyContinue`, + bins, dir, brand.ShortcutName(), key, dir) if err := os.WriteFile(uninstallScript, []byte(script), 0o644); err != nil { return err } @@ -167,15 +195,15 @@ Remove-Item -Path %q -Recurse -Force -ErrorAction SilentlyContinue`, dir, dir) uninstallCmd := fmt.Sprintf(`powershell -NoProfile -ExecutionPolicy Bypass -File "%s"`, uninstallScript) reg := fmt.Sprintf(` New-Item -Path %q -Force | Out-Null -Set-ItemProperty -Path %q -Name 'DisplayName' -Value 'Clawtool' +Set-ItemProperty -Path %q -Name 'DisplayName' -Value %q Set-ItemProperty -Path %q -Name 'DisplayVersion' -Value %q -Set-ItemProperty -Path %q -Name 'Publisher' -Value 'Cogitave' +Set-ItemProperty -Path %q -Name 'Publisher' -Value %q Set-ItemProperty -Path %q -Name 'DisplayIcon' -Value %q Set-ItemProperty -Path %q -Name 'InstallLocation' -Value %q Set-ItemProperty -Path %q -Name 'UninstallString' -Value %q Set-ItemProperty -Path %q -Name 'NoModify' -Value 1 -Type DWord Set-ItemProperty -Path %q -Name 'NoRepair' -Value 1 -Type DWord`, - key, key, key, version, key, key, icon, key, dir, key, uninstallCmd, key, key) + key, key, brand.Display, key, version, key, brand.Publisher, key, icon, key, dir, key, uninstallCmd, key, key) return powershell(reg) } diff --git a/cmd/clawtool-installer/install/install_test.go b/cmd/clawtool-installer/install/install_test.go index a14a50d..0714635 100644 --- a/cmd/clawtool-installer/install/install_test.go +++ b/cmd/clawtool-installer/install/install_test.go @@ -27,7 +27,7 @@ func TestCopyFromFS(t *testing.T) { func TestPayloadBinariesIncludesAll(t *testing.T) { want := map[string]bool{"Clawtool.exe": false, "clawtool.exe": false, "ClawtoolUpdate.exe": false} - for _, b := range PayloadBinaries { + for _, b := range PayloadBinaries() { want[b] = true } for name, found := range want { diff --git a/cmd/clawtool-installer/main.go b/cmd/clawtool-installer/main.go index 46552be..0e66e54 100644 --- a/cmd/clawtool-installer/main.go +++ b/cmd/clawtool-installer/main.go @@ -72,7 +72,7 @@ func main() { MaxWidth: 520, MinHeight: 420, MaxHeight: 420, - BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 255}, + BackgroundColour: &options.RGBA{R: 24, G: 24, B: 24, A: 255}, // Closing the window hides it — clawtool stays in the system // tray as the running gateway. Quit from the tray menu. HideWindowOnClose: true, diff --git a/cmd/clawtool-installer/payload.go b/cmd/clawtool-installer/payload.go index cdda7c3..3b08e59 100644 --- a/cmd/clawtool-installer/payload.go +++ b/cmd/clawtool-installer/payload.go @@ -3,6 +3,8 @@ package main import ( "embed" "io/fs" + + "github.com/cogitave/clawtool/cmd/clawtool-installer/brand" ) // payloadFS holds the binaries the setup build lays down. CI fills ./payload @@ -22,6 +24,6 @@ func payloadPresent() bool { if err != nil { return false } - _, err = fs.Stat(root, "Clawtool.exe") + _, err = fs.Stat(root, brand.AppExe()) return err == nil } From 494d82d5e760c7487fd049730e9ab4730b2d1369 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 01:38:40 +0300 Subject: [PATCH 07/86] feat(app): redesign the desktop app in the calm dark language Apply the same grounded design to the app shell and Home/Network/Updates views: drop the square logo mark for a plain wordmark, replace the gradient brand/buttons, the card gradients, the drop shadows and the Home glow with flat opacity-layered surfaces, hairline borders and the single cool accent used sparingly. The wordmark and Home headline now read the product name from App.Brand so nothing is hardcoded. --- .../frontend/dist/index.html | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/cmd/clawtool-installer/frontend/dist/index.html b/cmd/clawtool-installer/frontend/dist/index.html index 5dcf7fb..0bb824d 100644 --- a/cmd/clawtool-installer/frontend/dist/index.html +++ b/cmd/clawtool-installer/frontend/dist/index.html @@ -157,25 +157,23 @@ .switch input:checked ~ .knob { transform: translateX(16px); } .switch.busy { opacity: .55; pointer-events: none; } - /* ── polish: gradient brand + accent, surface depth, warmth ── */ - .brand { padding: 4px 8px 20px; gap: 11px; } - .brand .bmark { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; background: var(--accent-grad); box-shadow: 0 3px 12px rgba(34,176,220,.34); flex: none; } - .brand .bmark span { font-weight: 750; font-size: 17px; color: #fff; line-height: 1; } - .brand .name { font-size: 15px; font-weight: 650; letter-spacing: -0.01em; } - .btn.primary { background: var(--accent-grad); border: none; color: #fff; box-shadow: 0 2px 12px rgba(34,176,220,.26); } - .btn.primary:hover { filter: brightness(1.07); color: #fff; } - .navitem { border-radius: 9px; } + /* ── Warp-calm app: wordmark (no logo), flat layered surfaces, + hairline borders, one accent used sparingly, no gradients/glows. */ + .brand { padding: 4px 8px 22px; gap: 11px; } + .brand .name { font-size: 15px; font-weight: 600; letter-spacing: -0.01em; color: var(--fg); } + .brand .name .dot { color: var(--accent); } + .btn.primary { background: var(--accent); border: 1px solid transparent; color: var(--accent-ink); box-shadow: none; } + .btn.primary:hover { background: #8ab9ca; color: var(--accent-ink); filter: none; } + .navitem { border-radius: var(--radius-sm); } .navitem.active { background: var(--accent-soft); color: var(--fg); box-shadow: none; } .navitem.active .ic { color: var(--accent); } - .navitem .badge-dot { background: var(--accent-2); } - .card, .tile, .xd, .note { box-shadow: var(--shadow); } - .card, .tile, .xd { background: linear-gradient(180deg, color-mix(in srgb, var(--panel) 96%, #fff) 0%, var(--panel) 58%); } + .navitem .badge-dot { background: var(--accent); } + .card, .tile, .xd, .note, .banner { box-shadow: none; background: var(--panel-2); } .tile { transition: border-color .15s; } .tile[data-view]:hover { border-color: var(--hair-strong); } - .keybox { background: rgba(0,0,0,.22); } + .input { background: var(--panel-2); } + .keybox { background: var(--panel-2); } #view-home { position: relative; } - #view-home::before { content: ""; position: absolute; top: -30px; left: -30px; width: 560px; height: 300px; background: radial-gradient(600px 300px at 12% 0%, rgba(34,176,220,.13), transparent 68%); pointer-events: none; z-index: 0; } - #view-home > * { position: relative; z-index: 1; } .content::-webkit-scrollbar { width: 11px; } .content::-webkit-scrollbar-thumb { background: var(--hair-strong); border-radius: 8px; border: 3px solid transparent; background-clip: content-box; } @@ -320,8 +318,7 @@

      is ready

      -
      - -
      -

      clawtool is running

      -
      This device is a gateway on your network
      +
      + +
      +

      clawtool is running

      +
      This device is a gateway on your network
      -
      -
      Local agents
      -
      LAN peers
      -
      Cross-device
      +
      +
      Local agents
      +
      LAN peers
      +
      Cross-device
      -

      Quick actions

      -
      - - +

      Agents on this device

      +
      +
      + Closing the window keeps clawtool running in the menu bar. +
      -
      Closing the window keeps clawtool running in the menu bar.
      @@ -360,15 +430,15 @@

      Network

      Updates

      -
      clawtool checks for a new version each time it launches.
      -
      +
      clawtool checks for a new version each time it launches.
      +
      Installed
      Latest
      Statuschecking…
      -
      - - -
      +
      +
      + +

      @@ -510,8 +580,13 @@

      Updates

      const s = Math.max(0, Math.round((Date.now() - t) / 1000)); if (s < 5) return "just now"; if (s < 60) return s + "s ago"; if (s < 3600) return Math.floor(s/60) + "m ago"; return Math.floor(s/3600) + "h ago"; } function agentRow(a) { - const cls = a.callable ? "ok" : (a.status === "disabled" ? "off" : "busy"); - return '
      ' + esc(a.status || (a.callable ? "callable" : "?")) + '' + esc(a.instance) + '' + esc(a.family) + (a.bridge ? " · " + esc(a.bridge) : "") + "
      "; + const dis = a.status === "disabled"; + const stcls = dis ? "off" : (a.callable ? "" : "busy"); + const sttext = a.status || (a.callable ? "callable" : "—"); + const mono = esc((a.instance || "?").slice(0, 2)); + return '
      ' + mono + '
      ' + esc(a.instance) + + '
      ' + esc(a.family) + (a.bridge ? " · " + esc(a.bridge) : "") + '
      ' + + '' + esc(sttext) + "
      "; } function cssEsc(s) { return String(s).replace(/["\\]/g, "\\$&"); } async function loadNetwork() { @@ -527,8 +602,8 @@

      Updates

      banner.className = "banner"; const agents = (snap.agents && snap.agents.agents) || []; $("local").innerHTML = agents.length - ? '
      This machine
      ' + agents.length + " agent" + (agents.length === 1 ? "" : "s") + '
      ' + agents.map(agentRow).join("") + "
      " - : '
      No agents configured here yet. Run clawtool onboard to add one.
      '; + ? '
      ' + agents.map(agentRow).join("") + "
      " + : '
      No agents here yet — run ' + esc(BRAND.cli) + ' onboard to connect one.
      '; const peers = (snap.peers && snap.peers.peers) || []; $("peer-count").textContent = peers.length ? "(" + peers.length + ")" : ""; const pe = $("peers"); @@ -681,10 +756,15 @@

      Updates

      const snap = parse(await call("NetworkSnapshot"), { ok: false }); const ok = !!(snap && snap.ok); $("home-dot").classList.toggle("off", !ok); - $("home-host").textContent = ok ? "This device is a gateway on your network" : "Starting the local gateway…"; + $("home-title").innerHTML = ok ? '' + esc(BRAND.name) + " is running" : "Starting the gateway…"; + $("home-host").textContent = ok ? "This device is reachable as a " + BRAND.name + " gateway." : "Bringing the local gateway online…"; if (!ok) call("EnsureGateway"); - $("stat-agents").textContent = ok && snap.agents && snap.agents.count != null ? snap.agents.count : "0"; + const agents = (ok && snap.agents && snap.agents.agents) || []; + $("stat-agents").textContent = ok && snap.agents && snap.agents.count != null ? snap.agents.count : agents.length; $("stat-peers").textContent = ok && snap.peers && snap.peers.count != null ? snap.peers.count : "0"; + $("home-agents").innerHTML = agents.length + ? agents.map(agentRow).join("") + : '
      No agents here yet — run ' + esc(BRAND.cli) + ' onboard to connect one.
      '; const st = parse(await call("CircleStatus"), { ok: false }); circleState = (st && st.ok) ? st : { has_key: false, key: "" }; refreshXdStat(); From e14aba2a580d5235565587e85e32477aeaa4dad7 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 03:28:05 +0300 Subject: [PATCH 11/86] fix(setup): let file handles release before overwrite on upgrade Add a short settle delay after taskkill in stopRunning so an upgrade-over-running install doesn't hit a file lock when overwriting the binaries the old app/tray/daemon just held open (mirrors the old NSIS Sleep). The old app, tray and daemon are stopped before install; the Done step relaunches the freshly-installed app. --- cmd/clawtool-installer/install/install.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cmd/clawtool-installer/install/install.go b/cmd/clawtool-installer/install/install.go index cd4a76e..2d35c89 100644 --- a/cmd/clawtool-installer/install/install.go +++ b/cmd/clawtool-installer/install/install.go @@ -27,6 +27,7 @@ import ( "path/filepath" "runtime" "strings" + "time" "github.com/cogitave/clawtool/cmd/clawtool-installer/brand" ) @@ -156,12 +157,16 @@ func stopRunning(dir string) { if runtime.GOOS != "windows" { return } - // Best-effort: stop the daemon and kill running images so the .exe files - // aren't locked while we overwrite them. + // Best-effort: stop the daemon and kill running images (app, tray, CLI, + // updater) so the .exe files aren't locked while we overwrite them. _ = exec.Command(filepath.Join(CLIDir(dir), brand.CLIExe()), "daemon", "stop").Run() for _, img := range imageNames() { _ = exec.Command("taskkill", "/F", "/IM", img).Run() } + // taskkill returns before the OS has fully released the file handles; + // give them a moment so the subsequent overwrite doesn't hit a lock + // ("file in use") on an upgrade-over-running install. + time.Sleep(700 * time.Millisecond) } // windowsShortcuts writes Start-menu + Desktop .lnk files via WScript.Shell. From c54206d89fe0da8e0ecadd5a6a668f6e6ad331f4 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 03:33:30 +0300 Subject: [PATCH 12/86] feat(app): brand the first-launch splash (interim, pre-refactor) --- .../frontend/dist/index.html | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/cmd/clawtool-installer/frontend/dist/index.html b/cmd/clawtool-installer/frontend/dist/index.html index 619b931..36265fd 100644 --- a/cmd/clawtool-installer/frontend/dist/index.html +++ b/cmd/clawtool-installer/frontend/dist/index.html @@ -305,6 +305,12 @@ .banner { border: none; background: var(--panel-2); box-shadow: none; } .btn { background: var(--panel-2); border-color: var(--hair); } .btn:hover { border-color: var(--accent); color: var(--accent); } + + /* First-launch splash branding */ + .init-mark { display: flex; align-items: center; justify-content: space-between; width: 100%; max-width: 440px; margin-bottom: 20px; } + .init-mark > span:first-child { font-weight: 600; font-size: 14px; color: var(--fg); } + .init-mark .dot { color: var(--accent); } + #phase-init h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; } @@ -315,10 +321,11 @@

      clawtool

      Checking for updates…
      - +
      -

      Initializing clawtool

      -
      Setting this device up as a gateway…
      +
      clawtool.first launch
      +

      Setting up clawtool

      +
      Bringing your gateway online…
      0%
        @@ -492,15 +499,17 @@

        Updates

        function initFinish(ev) { const ok = !!ev.ok; setProgress(ok ? 100 : displayPct); - $("initTitle").textContent = ok ? "clawtool is ready" : "Setup needs attention"; - $("initSub").textContent = ok ? "This device is initialized." : "Review the steps above."; + $("initTitle").textContent = ok ? BRAND.name + " is ready" : "Setup needs attention"; + $("initSub").textContent = ok ? "Your gateway is live on this device." : "Review the steps above."; const d = $("initDone"); d.style.display = "block"; d.innerHTML = ok - ? "The clawtool command is now on your PATH — open a new terminal to use it." + ? "The " + esc(BRAND.cli) + " command is on your PATH — open a new terminal to use it." : esc(ev.summary || "Something didn't finish. You can retry from the tray."); if (ok) setTimeout(enterApp, 1100); } - function runInitializing() { + async function runInitializing() { + await loadBrand(); applyBrand(); + $("initTitle").textContent = "Setting up " + BRAND.name; showPhase("init"); setProgress(5); if (window.runtime && window.runtime.EventsOn) { window.runtime.EventsOn("install:step", addStep); From 26370406a5276a644fdfd978989555016d0e115c Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 04:07:17 +0300 Subject: [PATCH 13/86] feat(desktop): scaffold frontend monorepo + design-system foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Begin the proper UI architecture refactor (replacing the single hand-written index.html): a pnpm workspace under desktop/ with a shared @clawtool/design-system package — Warp-grounded design tokens (CSS custom properties, 3-tier), global base, and the first React components (Wordmark, StatusDot, Badge, Button, Metric, Section, AgentRow, and the frameless cross-platform TitleBar with platform-conditional window controls). A _gallery app verifies it builds with Vite and renders. This is the shared base the installer/updater/app surfaces and the split Go binaries will consume. --- desktop/.gitignore | 4 + desktop/apps/_gallery/index.html | 11 + desktop/apps/_gallery/package.json | 22 + desktop/apps/_gallery/src/main.tsx | 81 ++ desktop/apps/_gallery/src/vite-env.d.ts | 1 + desktop/apps/_gallery/tsconfig.json | 8 + desktop/apps/_gallery/vite.config.ts | 6 + desktop/package.json | 12 + desktop/packages/design-system/package.json | 23 + .../src/components/AgentRow.module.css | 38 + .../design-system/src/components/AgentRow.tsx | 33 + .../src/components/Badge.module.css | 11 + .../design-system/src/components/Badge.tsx | 8 + .../src/components/Button.module.css | 19 + .../design-system/src/components/Button.tsx | 17 + .../src/components/Metric.module.css | 16 + .../design-system/src/components/Metric.tsx | 20 + .../src/components/Section.module.css | 13 + .../design-system/src/components/Section.tsx | 11 + .../src/components/StatusDot.module.css | 24 + .../src/components/StatusDot.tsx | 7 + .../src/components/TitleBar.module.css | 34 + .../design-system/src/components/TitleBar.tsx | 47 + .../src/components/Wordmark.module.css | 8 + .../design-system/src/components/Wordmark.tsx | 10 + desktop/packages/design-system/src/global.css | 35 + desktop/packages/design-system/src/index.ts | 9 + .../design-system/src/lib/platform.ts | 21 + desktop/packages/design-system/src/tokens.css | 72 + .../packages/design-system/src/types/css.d.ts | 5 + desktop/packages/design-system/tsconfig.json | 8 + desktop/pnpm-lock.yaml | 1181 +++++++++++++++++ desktop/pnpm-workspace.yaml | 3 + desktop/tsconfig.base.json | 19 + 34 files changed, 1837 insertions(+) create mode 100644 desktop/.gitignore create mode 100644 desktop/apps/_gallery/index.html create mode 100644 desktop/apps/_gallery/package.json create mode 100644 desktop/apps/_gallery/src/main.tsx create mode 100644 desktop/apps/_gallery/src/vite-env.d.ts create mode 100644 desktop/apps/_gallery/tsconfig.json create mode 100644 desktop/apps/_gallery/vite.config.ts create mode 100644 desktop/package.json create mode 100644 desktop/packages/design-system/package.json create mode 100644 desktop/packages/design-system/src/components/AgentRow.module.css create mode 100644 desktop/packages/design-system/src/components/AgentRow.tsx create mode 100644 desktop/packages/design-system/src/components/Badge.module.css create mode 100644 desktop/packages/design-system/src/components/Badge.tsx create mode 100644 desktop/packages/design-system/src/components/Button.module.css create mode 100644 desktop/packages/design-system/src/components/Button.tsx create mode 100644 desktop/packages/design-system/src/components/Metric.module.css create mode 100644 desktop/packages/design-system/src/components/Metric.tsx create mode 100644 desktop/packages/design-system/src/components/Section.module.css create mode 100644 desktop/packages/design-system/src/components/Section.tsx create mode 100644 desktop/packages/design-system/src/components/StatusDot.module.css create mode 100644 desktop/packages/design-system/src/components/StatusDot.tsx create mode 100644 desktop/packages/design-system/src/components/TitleBar.module.css create mode 100644 desktop/packages/design-system/src/components/TitleBar.tsx create mode 100644 desktop/packages/design-system/src/components/Wordmark.module.css create mode 100644 desktop/packages/design-system/src/components/Wordmark.tsx create mode 100644 desktop/packages/design-system/src/global.css create mode 100644 desktop/packages/design-system/src/index.ts create mode 100644 desktop/packages/design-system/src/lib/platform.ts create mode 100644 desktop/packages/design-system/src/tokens.css create mode 100644 desktop/packages/design-system/src/types/css.d.ts create mode 100644 desktop/packages/design-system/tsconfig.json create mode 100644 desktop/pnpm-lock.yaml create mode 100644 desktop/pnpm-workspace.yaml create mode 100644 desktop/tsconfig.base.json diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..62ccde4 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store diff --git a/desktop/apps/_gallery/index.html b/desktop/apps/_gallery/index.html new file mode 100644 index 0000000..51ef382 --- /dev/null +++ b/desktop/apps/_gallery/index.html @@ -0,0 +1,11 @@ + + + + + design system gallery + + +
        + + + diff --git a/desktop/apps/_gallery/package.json b/desktop/apps/_gallery/package.json new file mode 100644 index 0000000..4c916fa --- /dev/null +++ b/desktop/apps/_gallery/package.json @@ -0,0 +1,22 @@ +{ + "name": "@clawtool/gallery", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clawtool/design-system": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.7" + } +} diff --git a/desktop/apps/_gallery/src/main.tsx b/desktop/apps/_gallery/src/main.tsx new file mode 100644 index 0000000..b6abbf5 --- /dev/null +++ b/desktop/apps/_gallery/src/main.tsx @@ -0,0 +1,81 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "@clawtool/design-system/global.css"; +import { + AgentRow, + Badge, + Button, + EmptyRow, + List, + Metric, + MetricStrip, + SectionHeader, + StatusDot, + TitleBar, + Wordmark, +} from "@clawtool/design-system"; + +const noop = () => {}; + +function Gallery() { + return ( +
        + } + controls={{ onMinimize: noop, onToggleMaximize: noop, onClose: noop }} + /> +
        + {/* status hero */} +
        + +
        +

        Gateway active

        +
        + This device is reachable as a clawtool gateway. +
        +
        +
        +
        mac-studio.local
        +
        +
        + +
        + + + + + +
        + + Network →} /> + + + + + + +
        + + + + accent + online + busy + neutral +
        +
        + + No peers discovered yet — start clawtool on another machine. + +
        +
        +
        + ); +} + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/desktop/apps/_gallery/src/vite-env.d.ts b/desktop/apps/_gallery/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/desktop/apps/_gallery/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/desktop/apps/_gallery/tsconfig.json b/desktop/apps/_gallery/tsconfig.json new file mode 100644 index 0000000..fe31a3a --- /dev/null +++ b/desktop/apps/_gallery/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/desktop/apps/_gallery/vite.config.ts b/desktop/apps/_gallery/vite.config.ts new file mode 100644 index 0000000..081c8d9 --- /dev/null +++ b/desktop/apps/_gallery/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 0000000..cb81c61 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,12 @@ +{ + "name": "clawtool-desktop", + "private": true, + "type": "module", + "scripts": { + "typecheck": "pnpm -r typecheck", + "build": "pnpm -r build" + }, + "devDependencies": { + "typescript": "^5.6.3" + } +} diff --git a/desktop/packages/design-system/package.json b/desktop/packages/design-system/package.json new file mode 100644 index 0000000..33bbda9 --- /dev/null +++ b/desktop/packages/design-system/package.json @@ -0,0 +1,23 @@ +{ + "name": "@clawtool/design-system", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./tokens.css": "./src/tokens.css", + "./global.css": "./src/global.css" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "typescript": "^5.6.3" + } +} diff --git a/desktop/packages/design-system/src/components/AgentRow.module.css b/desktop/packages/design-system/src/components/AgentRow.module.css new file mode 100644 index 0000000..c6cf730 --- /dev/null +++ b/desktop/packages/design-system/src/components/AgentRow.module.css @@ -0,0 +1,38 @@ +.list { margin-top: var(--space-1); } +.row { + display: flex; + align-items: center; + gap: var(--space-3); + padding: 11px 6px; + border-bottom: 1px solid var(--hairline); +} +.row:hover { background: var(--hover); } +.avatar { + width: 26px; + height: 26px; + border-radius: 7px; + background: var(--accent-soft); + color: var(--accent); + display: grid; + place-items: center; + font: 600 var(--size-xs) / 1 var(--font-mono); + flex: none; + text-transform: uppercase; +} +.name { font-weight: 550; font-size: var(--size-md); } +.meta { color: var(--text-secondary); font-size: var(--size-sm); margin-top: 1px; } +.status { + margin-left: auto; + display: flex; + align-items: center; + gap: 7px; + font-size: var(--size-sm); + color: var(--text-secondary); + white-space: nowrap; +} +.empty { + color: var(--text-secondary); + font-size: var(--size-md); + padding: 14px 6px; + border-bottom: 1px solid var(--hairline); +} diff --git a/desktop/packages/design-system/src/components/AgentRow.tsx b/desktop/packages/design-system/src/components/AgentRow.tsx new file mode 100644 index 0000000..b0cdd7e --- /dev/null +++ b/desktop/packages/design-system/src/components/AgentRow.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; +import { StatusDot, type DotTone } from "./StatusDot"; +import styles from "./AgentRow.module.css"; + +export function List({ children }: { children: ReactNode }) { + return
        {children}
        ; +} + +export function AgentRow({ + name, + meta, + tone = "online", + status, +}: { name: string; meta?: string; tone?: DotTone; status?: string }) { + const initials = name.slice(0, 2); + return ( +
        + {initials} +
        +
        {name}
        + {meta ?
        {meta}
        : null} +
        + + + {status ?? tone} + +
        + ); +} + +export function EmptyRow({ children }: { children: ReactNode }) { + return
        {children}
        ; +} diff --git a/desktop/packages/design-system/src/components/Badge.module.css b/desktop/packages/design-system/src/components/Badge.module.css new file mode 100644 index 0000000..bd49ade --- /dev/null +++ b/desktop/packages/design-system/src/components/Badge.module.css @@ -0,0 +1,11 @@ +.badge { + font: 500 var(--size-xs) / 1 var(--font-mono); + padding: 3px 9px; + border-radius: var(--radius-pill); + border: 1px solid var(--hairline); + color: var(--text-tertiary); + white-space: nowrap; +} +.accent { color: var(--accent); border-color: var(--accent-soft); } +.success { color: var(--success); border-color: rgba(76, 195, 138, 0.4); } +.warning { color: var(--warning); border-color: rgba(216, 166, 87, 0.4); } diff --git a/desktop/packages/design-system/src/components/Badge.tsx b/desktop/packages/design-system/src/components/Badge.tsx new file mode 100644 index 0000000..917fb05 --- /dev/null +++ b/desktop/packages/design-system/src/components/Badge.tsx @@ -0,0 +1,8 @@ +import type { ReactNode } from "react"; +import styles from "./Badge.module.css"; + +export type BadgeTone = "neutral" | "accent" | "success" | "warning"; + +export function Badge({ tone = "neutral", children }: { tone?: BadgeTone; children: ReactNode }) { + return {children}; +} diff --git a/desktop/packages/design-system/src/components/Button.module.css b/desktop/packages/design-system/src/components/Button.module.css new file mode 100644 index 0000000..93cb31f --- /dev/null +++ b/desktop/packages/design-system/src/components/Button.module.css @@ -0,0 +1,19 @@ +.btn { + font: 600 var(--size-md) / 1 var(--font-ui); + height: 34px; + padding: 0 16px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + cursor: pointer; + transition: background 0.14s, border-color 0.14s, color 0.14s; +} +.btn:disabled { opacity: 0.5; cursor: default; } + +.primary { background: var(--accent); color: var(--accent-ink); } +.primary:not(:disabled):hover { background: var(--accent-hover); } + +.secondary { background: var(--surface); border-color: var(--hairline); color: var(--text); } +.secondary:not(:disabled):hover { border-color: var(--accent); color: var(--accent); } + +.ghost { background: transparent; color: var(--text-secondary); padding: 0; height: auto; } +.ghost:not(:disabled):hover { color: var(--accent); } diff --git a/desktop/packages/design-system/src/components/Button.tsx b/desktop/packages/design-system/src/components/Button.tsx new file mode 100644 index 0000000..90c281c --- /dev/null +++ b/desktop/packages/design-system/src/components/Button.tsx @@ -0,0 +1,17 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import styles from "./Button.module.css"; + +type Variant = "primary" | "secondary" | "ghost"; + +export function Button({ + variant = "secondary", + children, + className = "", + ...rest +}: { variant?: Variant; children: ReactNode } & ButtonHTMLAttributes) { + return ( + + ); +} diff --git a/desktop/packages/design-system/src/components/Metric.module.css b/desktop/packages/design-system/src/components/Metric.module.css new file mode 100644 index 0000000..c8b518e --- /dev/null +++ b/desktop/packages/design-system/src/components/Metric.module.css @@ -0,0 +1,16 @@ +.strip { display: flex; } +.metric { + padding-right: var(--space-8); + margin-right: var(--space-8); + border-right: 1px solid var(--hairline); +} +.metric:last-child { border-right: none; margin-right: 0; padding-right: 0; } +.value { + font-size: 30px; + font-weight: 600; + letter-spacing: -0.025em; + font-variant-numeric: tabular-nums; + line-height: 1.05; +} +.dim { color: var(--text-tertiary); } +.label { font-size: var(--size-xs); color: var(--text-secondary); margin-top: 6px; } diff --git a/desktop/packages/design-system/src/components/Metric.tsx b/desktop/packages/design-system/src/components/Metric.tsx new file mode 100644 index 0000000..c8024fa --- /dev/null +++ b/desktop/packages/design-system/src/components/Metric.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from "react"; +import styles from "./Metric.module.css"; + +export function MetricStrip({ children }: { children: ReactNode }) { + return
        {children}
        ; +} + +export function Metric({ + value, + label, + dim = false, + onClick, +}: { value: ReactNode; label: string; dim?: boolean; onClick?: () => void }) { + return ( +
        +
        {value}
        +
        {label}
        +
        + ); +} diff --git a/desktop/packages/design-system/src/components/Section.module.css b/desktop/packages/design-system/src/components/Section.module.css new file mode 100644 index 0000000..5e32dca --- /dev/null +++ b/desktop/packages/design-system/src/components/Section.module.css @@ -0,0 +1,13 @@ +.header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin: var(--space-8) 0 var(--space-1); +} +.title { + font-size: var(--size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-secondary); +} diff --git a/desktop/packages/design-system/src/components/Section.tsx b/desktop/packages/design-system/src/components/Section.tsx new file mode 100644 index 0000000..d46f199 --- /dev/null +++ b/desktop/packages/design-system/src/components/Section.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; +import styles from "./Section.module.css"; + +export function SectionHeader({ title, action }: { title: string; action?: ReactNode }) { + return ( +
        +

        {title}

        + {action} +
        + ); +} diff --git a/desktop/packages/design-system/src/components/StatusDot.module.css b/desktop/packages/design-system/src/components/StatusDot.module.css new file mode 100644 index 0000000..5fed848 --- /dev/null +++ b/desktop/packages/design-system/src/components/StatusDot.module.css @@ -0,0 +1,24 @@ +.dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex: none; + position: relative; + display: inline-block; +} +.online { background: var(--success); } +.busy { background: var(--warning); } +.offline { background: var(--text-tertiary); } + +.pulse { width: 11px; height: 11px; } +.pulse::after { + content: ""; + position: absolute; + inset: -5px; + border-radius: 50%; + border: 1px solid currentColor; + opacity: 0.35; +} +.online.pulse { color: var(--success); } +.busy.pulse { color: var(--warning); } +.offline.pulse::after { display: none; } diff --git a/desktop/packages/design-system/src/components/StatusDot.tsx b/desktop/packages/design-system/src/components/StatusDot.tsx new file mode 100644 index 0000000..30647cd --- /dev/null +++ b/desktop/packages/design-system/src/components/StatusDot.tsx @@ -0,0 +1,7 @@ +import styles from "./StatusDot.module.css"; + +export type DotTone = "online" | "busy" | "offline"; + +export function StatusDot({ tone = "online", pulse = false }: { tone?: DotTone; pulse?: boolean }) { + return ; +} diff --git a/desktop/packages/design-system/src/components/TitleBar.module.css b/desktop/packages/design-system/src/components/TitleBar.module.css new file mode 100644 index 0000000..11cbd14 --- /dev/null +++ b/desktop/packages/design-system/src/components/TitleBar.module.css @@ -0,0 +1,34 @@ +.bar { + display: flex; + align-items: center; + height: var(--titlebar-h); + padding: 0 var(--space-2); + background: var(--surface-recessed); + border-bottom: 1px solid var(--hairline); + flex: none; +} +.gutterMac { width: 70px; flex: none; } +.center { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: var(--space-2); + --wails-draggable: drag; +} +.controls { + display: flex; + --wails-draggable: no-drag; +} +.btn { + width: 44px; + height: var(--titlebar-h); + border: 0; + background: transparent; + color: var(--text-secondary); + display: grid; + place-items: center; + cursor: default; +} +.btn:hover { background: var(--hover); color: var(--text); } +.close:hover { background: #e81123; color: #fff; } diff --git a/desktop/packages/design-system/src/components/TitleBar.tsx b/desktop/packages/design-system/src/components/TitleBar.tsx new file mode 100644 index 0000000..715ba19 --- /dev/null +++ b/desktop/packages/design-system/src/components/TitleBar.tsx @@ -0,0 +1,47 @@ +import type { CSSProperties, ReactNode } from "react"; +import type { Platform } from "../lib/platform"; +import styles from "./TitleBar.module.css"; + +export type WindowControls = { + onMinimize: () => void; + onToggleMaximize: () => void; + onClose: () => void; +}; + +// Frameless custom titlebar. Controls sit on the LEFT for macOS (where the +// traffic lights live) and on the RIGHT elsewhere — the one platform +// difference users expect. The whole bar is the drag region; controls and +// `center` opt out of dragging. +export function TitleBar({ + platform, + center, + controls, +}: { platform: Platform; center?: ReactNode; controls: WindowControls }) { + const onMac = platform === "darwin"; + const buttons = ( +
        + + + +
        + ); + + return ( +
        + {onMac ? buttons : null} + {onMac ?
        : null} +
        {center}
        + {onMac ? null : buttons} +
        + ); +} diff --git a/desktop/packages/design-system/src/components/Wordmark.module.css b/desktop/packages/design-system/src/components/Wordmark.module.css new file mode 100644 index 0000000..dda71ca --- /dev/null +++ b/desktop/packages/design-system/src/components/Wordmark.module.css @@ -0,0 +1,8 @@ +.wm { + font-weight: 600; + letter-spacing: -0.01em; + color: var(--text); +} +.dot { + color: var(--accent); +} diff --git a/desktop/packages/design-system/src/components/Wordmark.tsx b/desktop/packages/design-system/src/components/Wordmark.tsx new file mode 100644 index 0000000..06cf73d --- /dev/null +++ b/desktop/packages/design-system/src/components/Wordmark.tsx @@ -0,0 +1,10 @@ +import styles from "./Wordmark.module.css"; + +export function Wordmark({ name = "clawtool", size = 15 }: { name?: string; size?: number }) { + return ( + + {name} + . + + ); +} diff --git a/desktop/packages/design-system/src/global.css b/desktop/packages/design-system/src/global.css new file mode 100644 index 0000000..a679a49 --- /dev/null +++ b/desktop/packages/design-system/src/global.css @@ -0,0 +1,35 @@ +/* Global resets + base, shared by every surface. Import once per app + (after tokens.css). */ +@import "./tokens.css"; + +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body, #root { height: 100%; } + +body { + font-family: var(--font-ui); + font-size: var(--size-md); + line-height: 1.45; + background: var(--bg); + color: var(--text); + -webkit-font-smoothing: antialiased; + -webkit-user-select: none; + user-select: none; + overflow: hidden; +} + +a { color: inherit; text-decoration: none; } +.nodrag { --wails-draggable: no-drag; } +button { font: inherit; color: inherit; } +code, .mono { font-family: var(--font-mono); } + +::-webkit-scrollbar { width: 9px; height: 9px; } +::-webkit-scrollbar-thumb { + background: var(--hairline-strong); + border-radius: var(--radius-lg); + border: 2px solid transparent; + background-clip: content-box; +} +::-webkit-scrollbar-track { background: transparent; } + +@keyframes ct-spin { to { transform: rotate(360deg); } } +@keyframes ct-fadein { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; transform: none; } } diff --git a/desktop/packages/design-system/src/index.ts b/desktop/packages/design-system/src/index.ts new file mode 100644 index 0000000..3b54688 --- /dev/null +++ b/desktop/packages/design-system/src/index.ts @@ -0,0 +1,9 @@ +export { Wordmark } from "./components/Wordmark"; +export { StatusDot, type DotTone } from "./components/StatusDot"; +export { Badge, type BadgeTone } from "./components/Badge"; +export { Button } from "./components/Button"; +export { Metric, MetricStrip } from "./components/Metric"; +export { SectionHeader } from "./components/Section"; +export { AgentRow, List, EmptyRow } from "./components/AgentRow"; +export { TitleBar, type WindowControls } from "./components/TitleBar"; +export { getPlatform, isMac, type Platform } from "./lib/platform"; diff --git a/desktop/packages/design-system/src/lib/platform.ts b/desktop/packages/design-system/src/lib/platform.ts new file mode 100644 index 0000000..bf609a3 --- /dev/null +++ b/desktop/packages/design-system/src/lib/platform.ts @@ -0,0 +1,21 @@ +// Platform of the host OS, read from the Wails runtime when present. +// Falls back to "web" when running in a plain browser (dev/preview). +export type Platform = "darwin" | "windows" | "linux" | "web"; + +type WailsEnv = { platform?: string }; + +export async function getPlatform(): Promise { + const rt = (globalThis as { runtime?: { Environment?: () => Promise } }).runtime; + try { + const env = rt?.Environment ? await rt.Environment() : undefined; + const p = env?.platform; + if (p === "darwin" || p === "windows" || p === "linux") return p; + } catch { + /* not under Wails */ + } + return "web"; +} + +export function isMac(p: Platform): boolean { + return p === "darwin"; +} diff --git a/desktop/packages/design-system/src/tokens.css b/desktop/packages/design-system/src/tokens.css new file mode 100644 index 0000000..0a04446 --- /dev/null +++ b/desktop/packages/design-system/src/tokens.css @@ -0,0 +1,72 @@ +/* + * Design tokens — single source of truth for the clawtool desktop UI. + * Three tiers: primitives -> semantic -> component. Grounded in a + * well-regarded terminal's shipped default dark theme: a calm #181818 base, + * opacity-layered neutral surfaces, ONE cool accent used sparingly, hairline + * borders, small radii, no gradients. + */ +:root { + /* ── 1. primitives ─────────────────────────────────────────── */ + --c-ink-0: #181818; /* base */ + --c-ink-1: #1c1c1c; /* +5% elevation */ + --c-ink-2: #202020; /* +10% elevation */ + --c-ink-3: #151515; /* recessed (sidebar) */ + --c-fg: 227, 227, 227; /* foreground as r,g,b for rgba() opacity tiers */ + --c-accent: #7cafc2; /* cool cyan-blue */ + --c-accent-hi: #8ab9ca; + --c-accent-ink: #0e1417; /* text on accent fills */ + --c-green: #4cc38a; + --c-amber: #d8a657; + --c-red: #e06c75; + + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-pill: 999px; + + --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; + --font-mono: "SF Mono", "JetBrains Mono", "Cascadia Code", ui-monospace, Menlo, Consolas, monospace; + + --size-xs: 11px; + --size-sm: 12px; + --size-md: 13px; + --size-lg: 16px; + --size-xl: 22px; + --size-2xl: 26px; + + /* ── 2. semantic ───────────────────────────────────────────── */ + --bg: var(--c-ink-0); + --surface: var(--c-ink-2); + --surface-recessed: var(--c-ink-3); + + --text: rgba(var(--c-fg), 0.92); + --text-secondary: rgba(var(--c-fg), 0.60); + --text-tertiary: rgba(var(--c-fg), 0.40); + --text-disabled: rgba(var(--c-fg), 0.22); + + --hairline: rgba(var(--c-fg), 0.10); + --hairline-strong: rgba(var(--c-fg), 0.16); + --hover: rgba(var(--c-fg), 0.04); + + --accent: var(--c-accent); + --accent-hover: var(--c-accent-hi); + --accent-soft: rgba(124, 175, 194, 0.14); + --accent-ink: var(--c-accent-ink); + + --success: var(--c-green); + --warning: var(--c-amber); + --danger: var(--c-red); + + --shadow-overlay: 0 12px 32px rgba(0, 0, 0, 0.45); + + /* window chrome */ + --titlebar-h: 38px; +} diff --git a/desktop/packages/design-system/src/types/css.d.ts b/desktop/packages/design-system/src/types/css.d.ts new file mode 100644 index 0000000..f8c72d2 --- /dev/null +++ b/desktop/packages/design-system/src/types/css.d.ts @@ -0,0 +1,5 @@ +declare module "*.module.css" { + const classes: { readonly [key: string]: string }; + export default classes; +} +declare module "*.css"; diff --git a/desktop/packages/design-system/tsconfig.json b/desktop/packages/design-system/tsconfig.json new file mode 100644 index 0000000..dee81a1 --- /dev/null +++ b/desktop/packages/design-system/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src"] +} diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml new file mode 100644 index 0000000..11f29da --- /dev/null +++ b/desktop/pnpm-lock.yaml @@ -0,0 +1,1181 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ^5.6.3 + version: 5.9.3 + + apps/_gallery: + dependencies: + '@clawtool/design-system': + specifier: workspace:* + version: link:../../packages/design-system + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.12 + version: 18.3.29 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.29) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.2) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.2 + + packages/design-system: + dependencies: + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.12 + version: 18.3.29 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.29) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.29': + resolution: {integrity: sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + baseline-browser-mapping@2.10.32: + resolution: {integrity: sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + electron-to-chromium@1.5.362: + resolution: {integrity: sha512-PUY2DrLvkjkUuWqq+KPL2iWshrJsZOcIojzRQ7eXFacc9dWga7MGMJAa15VbiejSZB1PAXaRLAiKgruHP8LB1w==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.46: + resolution: {integrity: sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@6.4.2: + resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.8': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.29)': + dependencies: + '@types/react': 18.3.29 + + '@types/react@18.3.29': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@6.4.2)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.2 + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.10.32: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.32 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.362 + node-releases: 2.0.46 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + caniuse-lite@1.0.30001793: {} + + convert-source-map@2.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + electron-to-chromium@1.5.362: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + node-releases@2.0.46: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-refresh@0.17.0: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + semver@6.3.1: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@6.4.2: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.16 + optionalDependencies: + fsevents: 2.3.3 + + yallist@3.1.1: {} diff --git a/desktop/pnpm-workspace.yaml b/desktop/pnpm-workspace.yaml new file mode 100644 index 0000000..0e5a073 --- /dev/null +++ b/desktop/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - "packages/*" + - "apps/*" diff --git a/desktop/tsconfig.base.json b/desktop/tsconfig.base.json new file mode 100644 index 0000000..c7ad63c --- /dev/null +++ b/desktop/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + } +} From 97bc29febefb267f539dd97fa93ba30e0f45ed33 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 04:17:02 +0300 Subject: [PATCH 14/86] feat(desktop): typed Go-binding bridge package @clawtool/bridge wraps the Wails-injected globals behind a typed, never-throw layer: App.* method wrappers (mode/brand/networkSnapshot/circle*/lan*/update/ setup/...) returning parsed types from the contract, runtime event on/emit, and Win window controls (minimise/toggleMaximise/...) + environmentPlatform. Surfaces consume this instead of touching window.go/window.runtime directly. --- desktop/packages/bridge/package.json | 15 +++++++ desktop/packages/bridge/src/app.ts | 55 ++++++++++++++++++++++++ desktop/packages/bridge/src/index.ts | 3 ++ desktop/packages/bridge/src/runtime.ts | 55 ++++++++++++++++++++++++ desktop/packages/bridge/src/types.ts | 58 ++++++++++++++++++++++++++ desktop/packages/bridge/src/wails.ts | 47 +++++++++++++++++++++ desktop/packages/bridge/tsconfig.json | 8 ++++ desktop/pnpm-lock.yaml | 6 +++ 8 files changed, 247 insertions(+) create mode 100644 desktop/packages/bridge/package.json create mode 100644 desktop/packages/bridge/src/app.ts create mode 100644 desktop/packages/bridge/src/index.ts create mode 100644 desktop/packages/bridge/src/runtime.ts create mode 100644 desktop/packages/bridge/src/types.ts create mode 100644 desktop/packages/bridge/src/wails.ts create mode 100644 desktop/packages/bridge/tsconfig.json diff --git a/desktop/packages/bridge/package.json b/desktop/packages/bridge/package.json new file mode 100644 index 0000000..5f35717 --- /dev/null +++ b/desktop/packages/bridge/package.json @@ -0,0 +1,15 @@ +{ + "name": "@clawtool/bridge", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.6.3" + } +} diff --git a/desktop/packages/bridge/src/app.ts b/desktop/packages/bridge/src/app.ts new file mode 100644 index 0000000..f7355e4 --- /dev/null +++ b/desktop/packages/bridge/src/app.ts @@ -0,0 +1,55 @@ +// Typed wrappers over the bound Go App methods. Each binary (app/setup/updater) +// binds its own subset; a method absent on the current binary resolves to its +// fallback (never throws), so a surface can call freely and render a calm +// empty/error state. +import { callJSON, callRaw } from "./wails"; +import type { + Brand, + CircleStatus, + LanStatus, + Mode, + NetworkSnapshot, + PeerAgentsResult, + Result, + UpdateInfo, +} from "./types"; + +const OK_FALSE: Result = { ok: false }; + +export const App = { + // identity / mode + mode: () => callRaw("Mode") as Promise, + brand: () => + callJSON("Brand", { name: "clawtool", cli: "clawtool", tagline: "", installDir: "", version: "" }), + isInitialized: async () => (await callRaw("IsInitialized")) === true, + + // window lifecycle + enterApp: () => callRaw("EnterApp"), + quit: () => callRaw("Quit"), + hideToTray: () => callRaw("HideToTray"), + + // installer (setup surface) + runSetup: () => callJSON("RunSetup", OK_FALSE), + openInstalled: () => callJSON("OpenInstalled", OK_FALSE), + + // first-launch init (app/installer surface) — fire-and-forget; streams events + install: () => callRaw("Install"), + + // gateway + network + ensureGateway: () => callJSON("EnsureGateway", OK_FALSE), + networkSnapshot: () => callJSON("NetworkSnapshot", { ok: false }), + peerAgents: (peerID: string) => callJSON("PeerAgents", { ok: false }, peerID), + + // updates + checkUpdate: () => callJSON("CheckUpdate", { ok: false }), + installUpdate: () => callJSON("InstallUpdate", OK_FALSE), + + // cross-device (circle key + LAN) + circleStatus: () => callJSON("CircleStatus", { ok: false }), + circleGenerate: () => callJSON("CircleGenerate", OK_FALSE), + circleSet: (key: string) => callJSON("CircleSet", OK_FALSE, key), + circleClear: () => callJSON("CircleClear", OK_FALSE), + lanStatus: () => callJSON("LanStatus", { ok: false }), + lanEnable: () => callJSON("LanEnable", OK_FALSE), + lanDisable: () => callJSON("LanDisable", OK_FALSE), +}; diff --git a/desktop/packages/bridge/src/index.ts b/desktop/packages/bridge/src/index.ts new file mode 100644 index 0000000..326439d --- /dev/null +++ b/desktop/packages/bridge/src/index.ts @@ -0,0 +1,3 @@ +export { App } from "./app"; +export { on, emit, Win, environmentPlatform } from "./runtime"; +export * from "./types"; diff --git a/desktop/packages/bridge/src/runtime.ts b/desktop/packages/bridge/src/runtime.ts new file mode 100644 index 0000000..5160425 --- /dev/null +++ b/desktop/packages/bridge/src/runtime.ts @@ -0,0 +1,55 @@ +// Wails runtime wrappers: events + window controls + environment. Never throw +// when not running under Wails (dev/preview in a plain browser). +import { runtime } from "./wails"; +import type { Platform } from "./types"; + +export function on(event: string, handler: (payload: T) => void): () => void { + const rt = runtime(); + const fn = rt?.["EventsOn"]; + if (typeof fn !== "function") return () => {}; + try { + const off = fn(event, handler as (...a: unknown[]) => void); + return typeof off === "function" ? (off as () => void) : () => {}; + } catch { + return () => {}; + } +} + +export function emit(event: string, payload?: unknown): void { + const fn = runtime()?.["EventsEmit"]; + if (typeof fn === "function") { + try { + fn(event, payload); + } catch { + /* not under Wails */ + } + } +} + +function callRuntime(name: string, ...args: unknown[]): unknown { + const fn = runtime()?.[name]; + if (typeof fn !== "function") return undefined; + try { + return fn(...args); + } catch { + return undefined; + } +} + +export const Win = { + minimise: () => callRuntime("WindowMinimise"), + toggleMaximise: () => callRuntime("WindowToggleMaximise"), + isMaximised: async () => (await callRuntime("WindowIsMaximised")) === true, + hide: () => callRuntime("WindowHide"), + show: () => callRuntime("WindowShow"), + center: () => callRuntime("WindowCenter"), + setSize: (w: number, h: number) => callRuntime("WindowSetSize", w, h), + quit: () => callRuntime("Quit"), +}; + +export async function environmentPlatform(): Promise { + const env = (await callRuntime("Environment")) as { platform?: string } | undefined; + const p = env?.platform; + if (p === "darwin" || p === "windows" || p === "linux") return p; + return "web"; +} diff --git a/desktop/packages/bridge/src/types.ts b/desktop/packages/bridge/src/types.ts new file mode 100644 index 0000000..17413fd --- /dev/null +++ b/desktop/packages/bridge/src/types.ts @@ -0,0 +1,58 @@ +// Data shapes exchanged with the Go backend. Mirrors the contract inventory +// of the bound App methods + daemon bodies the UI consumes. + +export type Mode = "app" | "installer" | "setup"; + +export type Platform = "darwin" | "windows" | "linux" | "web"; + +export type Brand = { + name: string; + cli: string; + tagline: string; + installDir: string; + version: string; +}; + +export type Agent = { + instance: string; + family: string; + bridge?: string; + status?: string; + callable?: boolean; +}; + +export type Peer = { + peer_id: string; + display_name?: string; + status?: string; + last_seen?: string; + metadata?: { hostname?: string; address?: string; peer_version?: string }; +}; + +export type NetworkSnapshot = + | { ok: true; agents?: { agents?: Agent[]; count?: number }; peers?: { peers?: Peer[]; count?: number } } + | { ok: false; error?: string }; + +export type PeerAgentsResult = + | { agents: Agent[] } + | { needs_circle_key: true } + | { not_in_circle: true } + | { ok: false; error?: string }; + +export type UpdateInfo = { + current?: string; + latest?: string; + update_available?: boolean; + found?: boolean; + ok?: boolean; + error?: string; +}; + +export type Result = { ok: boolean; error?: string; [k: string]: unknown }; +export type CircleStatus = { ok: boolean; has_key?: boolean; key?: string }; +export type LanStatus = { ok: boolean; enabled?: boolean }; + +// Wails runtime events emitted by the Go side. +export type InstallStep = { level?: "ok" | "warn" | "fail"; label?: string; message?: string; raw?: string }; +export type InstallDone = { ok?: boolean; summary?: string }; +export type SetupStep = { label?: string; detail?: string }; diff --git a/desktop/packages/bridge/src/wails.ts b/desktop/packages/bridge/src/wails.ts new file mode 100644 index 0000000..cfad33b --- /dev/null +++ b/desktop/packages/bridge/src/wails.ts @@ -0,0 +1,47 @@ +// Low-level access to the Wails-injected globals (window.go / window.runtime), +// with a never-throw contract: a missing method or a thrown call resolves to a +// fallback instead of crashing a surface. Surfaces never touch these globals +// directly — they go through app.ts / runtime.ts. + +type AppNamespace = Record Promise | unknown>; + +type WailsGlobals = { + go?: { main?: { App?: AppNamespace } }; + runtime?: Record unknown>; +}; + +function globals(): WailsGlobals { + return globalThis as unknown as WailsGlobals; +} + +export function appNamespace(): AppNamespace | null { + return globals().go?.main?.App ?? null; +} + +// Call a bound App method by name; resolves to null on any failure (missing +// binding, thrown error, not under Wails). +export async function callRaw(name: string, ...args: unknown[]): Promise { + const app = appNamespace(); + const fn = app?.[name]; + if (typeof fn !== "function") return null; + try { + return await fn(...args); + } catch { + return null; + } +} + +// Call a method that returns a JSON string, parsed to T; fallback on failure. +export async function callJSON(name: string, fallback: T, ...args: unknown[]): Promise { + const raw = await callRaw(name, ...args); + if (typeof raw !== "string") return raw == null ? fallback : (raw as T); + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +export function runtime(): Record unknown> | null { + return globals().runtime ?? null; +} diff --git a/desktop/packages/bridge/tsconfig.json b/desktop/packages/bridge/tsconfig.json new file mode 100644 index 0000000..dee81a1 --- /dev/null +++ b/desktop/packages/bridge/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src"] +} diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 11f29da..44c1ef1 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -40,6 +40,12 @@ importers: specifier: ^6.0.7 version: 6.4.2 + packages/bridge: + devDependencies: + typescript: + specifier: ^5.6.3 + version: 5.9.3 + packages/design-system: dependencies: react: From b0f5f6e66017dbe69d6ab2753209a95f4cf1134f Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 04:22:37 +0300 Subject: [PATCH 15/86] feat(desktop): app-ui surface (frameless shell + Home/Network/Updates) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The main app surface built on the design system + bridge: a frameless shell (custom TitleBar with platform-conditional window controls + slim Sidebar with accent-bar nav and a live status footer) and the three views — Home (status hero + inline metric strip + agent rows), Network (local agents, cross-device circle key + LAN switch, peers), Updates (key/value + actions). Adds Sidebar, Switch and KeyValue to the design system. Data flows through @clawtool/bridge; verified building with Vite and rendering at 980x660. --- desktop/apps/app-ui/index.html | 11 ++ desktop/apps/app-ui/package.json | 23 +++ desktop/apps/app-ui/src/App.module.css | 45 ++++++ desktop/apps/app-ui/src/App.tsx | 54 +++++++ desktop/apps/app-ui/src/icons.tsx | 33 ++++ desktop/apps/app-ui/src/main.tsx | 10 ++ desktop/apps/app-ui/src/views/Home.tsx | 87 +++++++++++ desktop/apps/app-ui/src/views/Network.tsx | 144 ++++++++++++++++++ desktop/apps/app-ui/src/views/Updates.tsx | 49 ++++++ desktop/apps/app-ui/src/vite-env.d.ts | 1 + desktop/apps/app-ui/tsconfig.json | 8 + desktop/apps/app-ui/vite.config.ts | 7 + .../src/components/KeyValue.module.css | 12 ++ .../design-system/src/components/KeyValue.tsx | 15 ++ .../src/components/Sidebar.module.css | 49 ++++++ .../design-system/src/components/Sidebar.tsx | 26 ++++ .../src/components/Switch.module.css | 30 ++++ .../design-system/src/components/Switch.tsx | 15 ++ desktop/packages/design-system/src/index.ts | 3 + desktop/pnpm-lock.yaml | 31 ++++ 20 files changed, 653 insertions(+) create mode 100644 desktop/apps/app-ui/index.html create mode 100644 desktop/apps/app-ui/package.json create mode 100644 desktop/apps/app-ui/src/App.module.css create mode 100644 desktop/apps/app-ui/src/App.tsx create mode 100644 desktop/apps/app-ui/src/icons.tsx create mode 100644 desktop/apps/app-ui/src/main.tsx create mode 100644 desktop/apps/app-ui/src/views/Home.tsx create mode 100644 desktop/apps/app-ui/src/views/Network.tsx create mode 100644 desktop/apps/app-ui/src/views/Updates.tsx create mode 100644 desktop/apps/app-ui/src/vite-env.d.ts create mode 100644 desktop/apps/app-ui/tsconfig.json create mode 100644 desktop/apps/app-ui/vite.config.ts create mode 100644 desktop/packages/design-system/src/components/KeyValue.module.css create mode 100644 desktop/packages/design-system/src/components/KeyValue.tsx create mode 100644 desktop/packages/design-system/src/components/Sidebar.module.css create mode 100644 desktop/packages/design-system/src/components/Sidebar.tsx create mode 100644 desktop/packages/design-system/src/components/Switch.module.css create mode 100644 desktop/packages/design-system/src/components/Switch.tsx diff --git a/desktop/apps/app-ui/index.html b/desktop/apps/app-ui/index.html new file mode 100644 index 0000000..ce34b60 --- /dev/null +++ b/desktop/apps/app-ui/index.html @@ -0,0 +1,11 @@ + + + + + clawtool + + +
        + + + diff --git a/desktop/apps/app-ui/package.json b/desktop/apps/app-ui/package.json new file mode 100644 index 0000000..5be3039 --- /dev/null +++ b/desktop/apps/app-ui/package.json @@ -0,0 +1,23 @@ +{ + "name": "@clawtool/app-ui", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clawtool/bridge": "workspace:*", + "@clawtool/design-system": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.7" + } +} diff --git a/desktop/apps/app-ui/src/App.module.css b/desktop/apps/app-ui/src/App.module.css new file mode 100644 index 0000000..0a1faf1 --- /dev/null +++ b/desktop/apps/app-ui/src/App.module.css @@ -0,0 +1,45 @@ +.shell { display: flex; flex-direction: column; height: 100vh; } +.body { display: flex; flex: 1; min-height: 0; } +.content { flex: 1; min-width: 0; overflow-y: auto; padding: 30px 36px; display: flex; flex-direction: column; } + +.status { display: flex; align-items: center; gap: 13px; } +.status h1 { font-size: var(--size-2xl); font-weight: 600; letter-spacing: -0.02em; } +.status .sub { color: var(--text-secondary); font-size: 13px; margin-top: 2px; } +.status .right { margin-left: auto; text-align: right; } +.status .host { font: 500 12px var(--font-mono); color: var(--text-secondary); } + +.metrics { margin: 28px 0 4px; } +.foot { + margin-top: auto; + padding-top: 18px; + color: var(--text-tertiary); + font-size: 12px; + display: flex; + align-items: center; + justify-content: space-between; +} +.vh { font-size: var(--size-xl); font-weight: 600; letter-spacing: -0.02em; } +.lead { color: var(--text-secondary); font-size: 13px; margin-top: 2px; } +.banner { + margin-bottom: 16px; + padding: 11px 14px; + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text-secondary); + font-size: 13px; +} +.xdesc { color: var(--text-secondary); font-size: 12.5px; margin-top: 8px; line-height: 1.5; max-width: 520px; } +.xrow { display: flex; align-items: center; gap: 12px; margin-top: 14px; } +.switchRow { + display: flex; + align-items: flex-start; + gap: 14px; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--hairline); + max-width: 560px; +} +.switchRow .txt { flex: 1; } +.switchRow .st { font-weight: 600; font-size: 13.5px; } +.switchRow .sd { color: var(--text-secondary); font-size: 12.5px; margin-top: 3px; line-height: 1.45; } +.actions { display: flex; gap: 18px; margin-top: 18px; } diff --git a/desktop/apps/app-ui/src/App.tsx b/desktop/apps/app-ui/src/App.tsx new file mode 100644 index 0000000..a6badd6 --- /dev/null +++ b/desktop/apps/app-ui/src/App.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from "react"; +import { NavItem, Sidebar, StatusDot, TitleBar, Wordmark, type Platform } from "@clawtool/design-system"; +import { App as Backend, Win, environmentPlatform, type Brand } from "@clawtool/bridge"; +import { HomeIcon, NetworkIcon, UpdatesIcon } from "./icons"; +import { Home } from "./views/Home"; +import { Network } from "./views/Network"; +import { Updates } from "./views/Updates"; +import styles from "./App.module.css"; + +type View = "home" | "network" | "updates"; + +export function App() { + const [platform, setPlatform] = useState("windows"); + const [brand, setBrand] = useState({ name: "clawtool", cli: "clawtool", tagline: "", installDir: "", version: "" }); + const [view, setView] = useState("home"); + + useEffect(() => { + environmentPlatform().then((p) => setPlatform(p === "web" ? "windows" : p)); + Backend.brand().then(setBrand); + Backend.ensureGateway(); + }, []); + + const controls = { + onMinimize: () => Win.minimise(), + onToggleMaximize: () => Win.toggleMaximise(), + onClose: () => Win.quit(), + }; + + return ( +
        + +
        + } + footer={ + <> + + {brand.version ? `v${brand.version}` : ""} + + } + > + } label="Home" active={view === "home"} onClick={() => setView("home")} /> + } label="Network" active={view === "network"} onClick={() => setView("network")} /> + } label="Updates" active={view === "updates"} onClick={() => setView("updates")} /> + +
        + {view === "home" ? setView(v as View)} /> : null} + {view === "network" ? : null} + {view === "updates" ? : null} +
        +
        +
        + ); +} diff --git a/desktop/apps/app-ui/src/icons.tsx b/desktop/apps/app-ui/src/icons.tsx new file mode 100644 index 0000000..9081a8a --- /dev/null +++ b/desktop/apps/app-ui/src/icons.tsx @@ -0,0 +1,33 @@ +const base = { + width: 16, + height: 16, + viewBox: "0 0 24 24", + fill: "none", + stroke: "currentColor", + strokeWidth: 1.8, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, +}; + +export const HomeIcon = () => ( + + + + +); + +export const NetworkIcon = () => ( + + + + + + +); + +export const UpdatesIcon = () => ( + + + + +); diff --git a/desktop/apps/app-ui/src/main.tsx b/desktop/apps/app-ui/src/main.tsx new file mode 100644 index 0000000..31cb538 --- /dev/null +++ b/desktop/apps/app-ui/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "@clawtool/design-system/global.css"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/desktop/apps/app-ui/src/views/Home.tsx b/desktop/apps/app-ui/src/views/Home.tsx new file mode 100644 index 0000000..6a7a2b5 --- /dev/null +++ b/desktop/apps/app-ui/src/views/Home.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from "react"; +import { + AgentRow, + Button, + EmptyRow, + List, + Metric, + MetricStrip, + SectionHeader, + StatusDot, +} from "@clawtool/design-system"; +import { App, type Agent, type Brand } from "@clawtool/bridge"; +import styles from "../App.module.css"; + +export function Home({ brand, onNavigate }: { brand: Brand; onNavigate: (v: string) => void }) { + const [online, setOnline] = useState(false); + const [agents, setAgents] = useState([]); + const [peers, setPeers] = useState(0); + const [xd, setXd] = useState(false); + + async function load() { + const snap = await App.networkSnapshot(); + const ok = snap.ok === true; + setOnline(ok); + if (!ok) { + App.ensureGateway(); + setAgents([]); + return; + } + setAgents(snap.agents?.agents ?? []); + setPeers(snap.peers?.count ?? snap.peers?.peers?.length ?? 0); + const c = await App.circleStatus(); + setXd(c.ok === true && c.has_key === true); + } + + useEffect(() => { + load(); + }, []); + + return ( + <> +
        + +
        +

        {online ? `${brand.name} is running` : "Starting the gateway…"}

        +
        + {online ? `This device is reachable as a ${brand.name} gateway.` : "Bringing the local gateway online…"} +
        +
        +
        + +
        + + + + onNavigate("network")} /> + +
        + + onNavigate("network")}>Network →} /> + + {agents.length ? ( + agents.map((a) => ( + + )) + ) : ( + + No agents here yet — run {brand.cli} onboard to connect one. + + )} + + +
        + Closing the window keeps {brand.name} running in the menu bar. + +
        + + ); +} diff --git a/desktop/apps/app-ui/src/views/Network.tsx b/desktop/apps/app-ui/src/views/Network.tsx new file mode 100644 index 0000000..5e357b9 --- /dev/null +++ b/desktop/apps/app-ui/src/views/Network.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { + AgentRow, + Badge, + Button, + EmptyRow, + KVRow, + KeyValue, + List, + SectionHeader, + Switch, +} from "@clawtool/design-system"; +import { App, type Agent, type Brand, type Peer } from "@clawtool/bridge"; +import styles from "../App.module.css"; + +function maskKey(k: string): string { + return k && k.length > 14 ? `${k.slice(0, 8)}…${k.slice(-4)}` : k; +} + +export function Network({ brand }: { brand: Brand }) { + const [banner, setBanner] = useState(""); + const [agents, setAgents] = useState([]); + const [peers, setPeers] = useState([]); + const [hasKey, setHasKey] = useState(false); + const [key, setKey] = useState(""); + const [lan, setLan] = useState(false); + const [lanBusy, setLanBusy] = useState(false); + + async function loadNetwork() { + const snap = await App.networkSnapshot(); + if (snap.ok !== true) { + setBanner("Starting the local gateway…"); + App.ensureGateway(); + return; + } + setBanner(""); + setAgents(snap.agents?.agents ?? []); + setPeers(snap.peers?.peers ?? []); + } + async function loadCircle() { + const c = await App.circleStatus(); + setHasKey(c.ok === true && c.has_key === true); + setKey(c.key ?? ""); + const l = await App.lanStatus(); + setLan(l.ok === true && l.enabled === true); + } + + useEffect(() => { + loadCircle(); + loadNetwork(); + const t = setInterval(loadNetwork, 5000); + return () => clearInterval(t); + }, []); + + async function toggleLan(next: boolean) { + setLanBusy(true); + await (next ? App.lanEnable() : App.lanDisable()); + await loadCircle(); + setLanBusy(false); + } + + return ( + <> + {banner ?
        {banner}
        : null} +

        This device

        +
        Agents running locally on this machine.
        + + {agents.length ? ( + agents.map((a) => ( + + )) + ) : ( + + No agents here yet — run {brand.cli} onboard to connect one. + + )} + + + +
        + Cross-device peering + + {hasKey ? "On" : "Off"} + +
        +
        + Create a circle to let your devices see each other's agents. Generate a key here, then join the same circle on + your other devices. +
        +
        + {hasKey ? ( + <> + {maskKey(key)} + + + ) : ( + <> + + + + )} +
        + +
        +
        +
        Reachable on your LAN
        +
        + Let circle devices on your network read this device's agent list. Code execution stays local-only; the + first time, your OS may ask to allow it through the firewall. +
        +
        + +
        + + + {peers.length ? ( + + {peers.map((p) => ( + + {p.status ?? "—"} + + ))} + + ) : ( + + + No peers discovered yet. Start {brand.name} on another machine on this network — paired devices appear + automatically over mDNS. + + + )} + + ); +} diff --git a/desktop/apps/app-ui/src/views/Updates.tsx b/desktop/apps/app-ui/src/views/Updates.tsx new file mode 100644 index 0000000..8f50551 --- /dev/null +++ b/desktop/apps/app-ui/src/views/Updates.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { Button, KVRow, KeyValue } from "@clawtool/design-system"; +import { App, type Brand, type UpdateInfo } from "@clawtool/bridge"; +import styles from "../App.module.css"; + +export function Updates({ brand }: { brand: Brand }) { + const [info, setInfo] = useState({}); + const [status, setStatus] = useState("checking…"); + const [busy, setBusy] = useState(false); + + async function check() { + setStatus("checking…"); + const u = await App.checkUpdate(); + setInfo(u); + if (u.ok === false) setStatus("check failed"); + else setStatus(u.update_available ? `v${u.latest} available` : "up to date"); + } + + useEffect(() => { + check(); + }, []); + + async function install() { + setBusy(true); + const r = await App.installUpdate(); + setStatus(r.ok ? `updated — restart ${brand.name} to finish` : "update failed"); + setBusy(false); + } + + return ( + <> +

        Updates

        +
        {brand.name} checks for a new version each time it launches.
        +
        + + {info.current ? `v${info.current}` : "—"} + {info.latest ? `v${info.latest}` : "—"} + {status} + +
        +
        + + +
        + + ); +} diff --git a/desktop/apps/app-ui/src/vite-env.d.ts b/desktop/apps/app-ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/desktop/apps/app-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/desktop/apps/app-ui/tsconfig.json b/desktop/apps/app-ui/tsconfig.json new file mode 100644 index 0000000..fe31a3a --- /dev/null +++ b/desktop/apps/app-ui/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/desktop/apps/app-ui/vite.config.ts b/desktop/apps/app-ui/vite.config.ts new file mode 100644 index 0000000..425c778 --- /dev/null +++ b/desktop/apps/app-ui/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { outDir: "dist" }, +}); diff --git a/desktop/packages/design-system/src/components/KeyValue.module.css b/desktop/packages/design-system/src/components/KeyValue.module.css new file mode 100644 index 0000000..9b089a8 --- /dev/null +++ b/desktop/packages/design-system/src/components/KeyValue.module.css @@ -0,0 +1,12 @@ +.kv { max-width: 540px; } +.row { + display: flex; + justify-content: space-between; + gap: var(--space-3); + padding: 10px 6px; + border-top: 1px solid var(--hairline); + font-size: var(--size-md); +} +.row:first-child { border-top: none; } +.k { color: var(--text-secondary); } +.v { color: var(--text); } diff --git a/desktop/packages/design-system/src/components/KeyValue.tsx b/desktop/packages/design-system/src/components/KeyValue.tsx new file mode 100644 index 0000000..d8db7c8 --- /dev/null +++ b/desktop/packages/design-system/src/components/KeyValue.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import styles from "./KeyValue.module.css"; + +export function KeyValue({ children }: { children: ReactNode }) { + return
        {children}
        ; +} + +export function KVRow({ k, children }: { k: string; children: ReactNode }) { + return ( +
        + {k} + {children} +
        + ); +} diff --git a/desktop/packages/design-system/src/components/Sidebar.module.css b/desktop/packages/design-system/src/components/Sidebar.module.css new file mode 100644 index 0000000..eb51127 --- /dev/null +++ b/desktop/packages/design-system/src/components/Sidebar.module.css @@ -0,0 +1,49 @@ +.side { + width: 208px; + flex: none; + background: var(--surface-recessed); + border-right: 1px solid var(--hairline); + display: flex; + flex-direction: column; + padding: 18px 12px 12px; +} +.top { padding: 0 8px 22px; } +.nav { display: flex; flex-direction: column; gap: 1px; } +.item { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 8px; + border: 0; + background: none; + border-radius: var(--radius-md); + color: var(--text-secondary); + font-size: var(--size-md); + font-weight: 500; + text-align: left; + cursor: pointer; + position: relative; +} +.item:hover { color: var(--text); background: var(--hover); } +.icon { width: 16px; height: 16px; flex: none; display: grid; place-items: center; } +.active { color: var(--text); } +.active::before { + content: ""; + position: absolute; + left: -12px; + top: 7px; + bottom: 7px; + width: 2px; + border-radius: 2px; + background: var(--accent); +} +.active .icon { color: var(--accent); } +.footer { + margin-top: auto; + display: flex; + align-items: center; + gap: 7px; + padding: 8px; + color: var(--text-tertiary); + font: 500 var(--size-xs) / 1 var(--font-mono); +} diff --git a/desktop/packages/design-system/src/components/Sidebar.tsx b/desktop/packages/design-system/src/components/Sidebar.tsx new file mode 100644 index 0000000..cf9c19a --- /dev/null +++ b/desktop/packages/design-system/src/components/Sidebar.tsx @@ -0,0 +1,26 @@ +import type { ReactNode } from "react"; +import styles from "./Sidebar.module.css"; + +export function Sidebar({ top, children, footer }: { top: ReactNode; children: ReactNode; footer?: ReactNode }) { + return ( + + ); +} + +export function NavItem({ + icon, + label, + active = false, + onClick, +}: { icon: ReactNode; label: string; active?: boolean; onClick?: () => void }) { + return ( + + ); +} diff --git a/desktop/packages/design-system/src/components/Switch.module.css b/desktop/packages/design-system/src/components/Switch.module.css new file mode 100644 index 0000000..89a3969 --- /dev/null +++ b/desktop/packages/design-system/src/components/Switch.module.css @@ -0,0 +1,30 @@ +.switch { + position: relative; + width: 40px; + height: 24px; + flex: none; + cursor: pointer; + display: inline-block; +} +.switch input { position: absolute; opacity: 0; width: 0; height: 0; } +.track { + position: absolute; + inset: 0; + background: var(--hairline-strong); + border-radius: var(--radius-pill); + transition: background 0.18s; +} +.switch input:checked ~ .track { background: var(--success); } +.knob { + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #fff; + transition: transform 0.18s; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); +} +.switch input:checked ~ .knob { transform: translateX(16px); } +.busy { opacity: 0.55; pointer-events: none; } diff --git a/desktop/packages/design-system/src/components/Switch.tsx b/desktop/packages/design-system/src/components/Switch.tsx new file mode 100644 index 0000000..cd7cde8 --- /dev/null +++ b/desktop/packages/design-system/src/components/Switch.tsx @@ -0,0 +1,15 @@ +import styles from "./Switch.module.css"; + +export function Switch({ + checked, + busy = false, + onChange, +}: { checked: boolean; busy?: boolean; onChange: (next: boolean) => void }) { + return ( + + ); +} diff --git a/desktop/packages/design-system/src/index.ts b/desktop/packages/design-system/src/index.ts index 3b54688..6166afc 100644 --- a/desktop/packages/design-system/src/index.ts +++ b/desktop/packages/design-system/src/index.ts @@ -6,4 +6,7 @@ export { Metric, MetricStrip } from "./components/Metric"; export { SectionHeader } from "./components/Section"; export { AgentRow, List, EmptyRow } from "./components/AgentRow"; export { TitleBar, type WindowControls } from "./components/TitleBar"; +export { Sidebar, NavItem } from "./components/Sidebar"; +export { Switch } from "./components/Switch"; +export { KeyValue, KVRow } from "./components/KeyValue"; export { getPlatform, isMac, type Platform } from "./lib/platform"; diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 44c1ef1..f018b00 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -40,6 +40,37 @@ importers: specifier: ^6.0.7 version: 6.4.2 + apps/app-ui: + dependencies: + '@clawtool/bridge': + specifier: workspace:* + version: link:../../packages/bridge + '@clawtool/design-system': + specifier: workspace:* + version: link:../../packages/design-system + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.12 + version: 18.3.29 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.29) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.2) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.2 + packages/bridge: devDependencies: typescript: From 4b01594c334bff1e05f73fe0cb24dfbe624157bd Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 04:29:37 +0300 Subject: [PATCH 16/86] feat(desktop): installer-ui + updater-ui surfaces + ProgressBar/LogFeed installer-ui: stepped Welcome -> Installing (live LogFeed + ProgressBar from setup:step) -> Done, frameless. updater-ui: minimal launch splash driven by an update:status event. Adds ProgressBar + LogFeed to the design system. All three surfaces (app/installer/updater) now build with Vite and typecheck clean. --- desktop/apps/installer-ui/index.html | 11 ++ desktop/apps/installer-ui/package.json | 23 +++ desktop/apps/installer-ui/src/App.tsx | 171 ++++++++++++++++++ .../installer-ui/src/Installer.module.css | 43 +++++ desktop/apps/installer-ui/src/main.tsx | 10 + desktop/apps/installer-ui/src/vite-env.d.ts | 1 + desktop/apps/installer-ui/tsconfig.json | 8 + desktop/apps/installer-ui/vite.config.ts | 7 + desktop/apps/updater-ui/index.html | 11 ++ desktop/apps/updater-ui/package.json | 23 +++ desktop/apps/updater-ui/src/App.tsx | 26 +++ .../apps/updater-ui/src/Updater.module.css | 20 ++ desktop/apps/updater-ui/src/main.tsx | 10 + desktop/apps/updater-ui/src/vite-env.d.ts | 1 + desktop/apps/updater-ui/tsconfig.json | 8 + desktop/apps/updater-ui/vite.config.ts | 7 + .../src/components/LogFeed.module.css | 26 +++ .../design-system/src/components/LogFeed.tsx | 29 +++ .../src/components/ProgressBar.module.css | 13 ++ .../src/components/ProgressBar.tsx | 10 + desktop/packages/design-system/src/index.ts | 2 + desktop/pnpm-lock.yaml | 62 +++++++ 22 files changed, 522 insertions(+) create mode 100644 desktop/apps/installer-ui/index.html create mode 100644 desktop/apps/installer-ui/package.json create mode 100644 desktop/apps/installer-ui/src/App.tsx create mode 100644 desktop/apps/installer-ui/src/Installer.module.css create mode 100644 desktop/apps/installer-ui/src/main.tsx create mode 100644 desktop/apps/installer-ui/src/vite-env.d.ts create mode 100644 desktop/apps/installer-ui/tsconfig.json create mode 100644 desktop/apps/installer-ui/vite.config.ts create mode 100644 desktop/apps/updater-ui/index.html create mode 100644 desktop/apps/updater-ui/package.json create mode 100644 desktop/apps/updater-ui/src/App.tsx create mode 100644 desktop/apps/updater-ui/src/Updater.module.css create mode 100644 desktop/apps/updater-ui/src/main.tsx create mode 100644 desktop/apps/updater-ui/src/vite-env.d.ts create mode 100644 desktop/apps/updater-ui/tsconfig.json create mode 100644 desktop/apps/updater-ui/vite.config.ts create mode 100644 desktop/packages/design-system/src/components/LogFeed.module.css create mode 100644 desktop/packages/design-system/src/components/LogFeed.tsx create mode 100644 desktop/packages/design-system/src/components/ProgressBar.module.css create mode 100644 desktop/packages/design-system/src/components/ProgressBar.tsx diff --git a/desktop/apps/installer-ui/index.html b/desktop/apps/installer-ui/index.html new file mode 100644 index 0000000..5ebd436 --- /dev/null +++ b/desktop/apps/installer-ui/index.html @@ -0,0 +1,11 @@ + + + + + Install clawtool + + +
        + + + diff --git a/desktop/apps/installer-ui/package.json b/desktop/apps/installer-ui/package.json new file mode 100644 index 0000000..322c88b --- /dev/null +++ b/desktop/apps/installer-ui/package.json @@ -0,0 +1,23 @@ +{ + "name": "@clawtool/installer-ui", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clawtool/bridge": "workspace:*", + "@clawtool/design-system": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.7" + } +} diff --git a/desktop/apps/installer-ui/src/App.tsx b/desktop/apps/installer-ui/src/App.tsx new file mode 100644 index 0000000..b982207 --- /dev/null +++ b/desktop/apps/installer-ui/src/App.tsx @@ -0,0 +1,171 @@ +import { useEffect, useRef, useState } from "react"; +import { + Badge, + Button, + LogFeed, + ProgressBar, + TitleBar, + Wordmark, + type LogLine, + type Platform, +} from "@clawtool/design-system"; +import { App as Backend, Win, environmentPlatform, on, type Brand, type SetupStep } from "@clawtool/bridge"; +import styles from "./Installer.module.css"; + +type Phase = "welcome" | "installing" | "done"; + +function clock(): string { + const d = new Date(); + return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":"); +} + +export function App() { + const [platform, setPlatform] = useState("windows"); + const [brand, setBrand] = useState({ name: "clawtool", cli: "clawtool", tagline: "", installDir: "", version: "" }); + const [phase, setPhase] = useState("welcome"); + const [lines, setLines] = useState([]); + const [pct, setPct] = useState(0); + const [now, setNow] = useState("Starting…"); + const [dir, setDir] = useState(""); + const [error, setError] = useState(""); + const pctRef = useRef(0); + const doneRef = useRef(false); + + useEffect(() => { + environmentPlatform().then((p) => setPlatform(p === "web" ? "windows" : p)); + Backend.brand().then((b) => { + setBrand(b); + setDir(b.installDir); + }); + }, []); + + function bump(to: number) { + pctRef.current = Math.max(pctRef.current, Math.min(to, 100)); + setPct(pctRef.current); + } + + async function startInstall() { + setPhase("installing"); + bump(4); + setLines([{ time: clock(), label: `beginning install of ${brand.name}`, tone: "ok" }]); + let n = 0; + const off = on("setup:step", (s) => { + if (doneRef.current || !s?.label) return; + setNow(`${s.label}…`); + setLines((prev) => [...prev, { time: clock(), label: s.label!, detail: s.detail, tone: "ok" }]); + n += 1; + bump(8 + Math.min(88, n * 9)); + }); + const r = await Backend.runSetup(); + doneRef.current = true; + off(); + bump(100); + if (r.ok) { + if (typeof r.dir === "string") setDir(r.dir); + setNow("Done"); + setLines((prev) => [...prev, { time: clock(), label: "install complete", tone: "done" }]); + setTimeout(() => setPhase("done"), 550); + } else { + setNow("Failed"); + setError(typeof r.error === "string" ? r.error : "install failed"); + setLines((prev) => [...prev, { time: clock(), label: (r.error as string) ?? "install failed", tone: "warn" }]); + } + } + + const controls = { + onMinimize: () => Win.minimise(), + onToggleMaximize: () => Win.toggleMaximise(), + onClose: () => Win.quit(), + }; + + return ( +
        + + + {phase === "welcome" ? ( +
        +
        + + {brand.version ? `v${brand.version}` : "setup"} +
        +
        +

        + Install {brand.name} +

        +

        {brand.tagline}

        +
        +
        + + The {brand.cli} command, added to your PATH. + +
        +
        + A desktop app that runs in your tray as the gateway. +
        +
        + Quiet self-updates — no admin prompts. +
        +
        +
        +
        + + {dir} +
        +
        + ) : null} + + {phase === "installing" ? ( +
        +
        +

        Installing

        + step 2 / 3 +
        +
        + +
        +
        + {error || now} + {Math.round(pct)}% +
        + +
        + ) : null} + + {phase === "done" ? ( +
        +
        + + installed +
        +
        +
        + + + +
        +

        {brand.name} is ready

        +

        + Installed to {dir}. Open a new terminal to use the {brand.cli} command. +

        +
        + + +
        +
        +
        + ) : null} +
        + ); +} diff --git a/desktop/apps/installer-ui/src/Installer.module.css b/desktop/apps/installer-ui/src/Installer.module.css new file mode 100644 index 0000000..c4e19c5 --- /dev/null +++ b/desktop/apps/installer-ui/src/Installer.module.css @@ -0,0 +1,43 @@ +.shell { display: flex; flex-direction: column; height: 100vh; } +.phase { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 26px 30px 22px; } + +.top { display: flex; align-items: center; justify-content: space-between; } + +/* Welcome */ +.hero { margin-top: auto; } +.title { font-size: var(--size-2xl); font-weight: 650; letter-spacing: -0.02em; } +.title .accent { color: var(--accent); } +.lede { color: var(--text-secondary); margin-top: 8px; max-width: 380px; font-size: 13.5px; line-height: 1.5; } +.facts { display: flex; flex-direction: column; gap: 9px; margin-top: 20px; } +.fact { display: flex; gap: 10px; align-items: flex-start; color: var(--text-secondary); font-size: 12.5px; } +.fact b { color: var(--text); font-weight: 550; } +.actions { margin-top: auto; padding-top: 24px; display: flex; align-items: center; gap: 12px; } +.loc { color: var(--text-tertiary); font: 500 11px var(--font-mono); margin-left: auto; } + +/* Installing */ +.ihead { display: flex; align-items: baseline; justify-content: space-between; } +.ihead h2 { font-size: var(--size-lg); font-weight: 600; } +.stepof { font: 500 11px var(--font-mono); color: var(--text-tertiary); } +.bar { margin: 14px 0 0; } +.nowline { display: flex; justify-content: space-between; color: var(--text-secondary); font-size: 12px; margin: 8px 0 14px; } +.pct { font: 500 12px var(--font-mono); color: var(--text-tertiary); } + +/* Done */ +.center { margin: auto 0; } +.check { + width: 40px; height: 40px; border-radius: 999px; + border: 1px solid var(--hairline-strong); + display: grid; place-items: center; margin-bottom: 16px; +} +.doneTitle { font-size: var(--size-xl); font-weight: 650; letter-spacing: -0.02em; } +.doneText { color: var(--text-secondary); margin-top: 8px; max-width: 400px; font-size: 13px; line-height: 1.5; } +.doneText b { color: var(--text); font-weight: 550; } +.doneText code { + font: 500 11.5px var(--font-mono); + color: var(--text); + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + padding: 2px 6px; +} +.doneActions { margin-top: 26px; display: flex; gap: 12px; } diff --git a/desktop/apps/installer-ui/src/main.tsx b/desktop/apps/installer-ui/src/main.tsx new file mode 100644 index 0000000..31cb538 --- /dev/null +++ b/desktop/apps/installer-ui/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "@clawtool/design-system/global.css"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/desktop/apps/installer-ui/src/vite-env.d.ts b/desktop/apps/installer-ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/desktop/apps/installer-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/desktop/apps/installer-ui/tsconfig.json b/desktop/apps/installer-ui/tsconfig.json new file mode 100644 index 0000000..fe31a3a --- /dev/null +++ b/desktop/apps/installer-ui/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/desktop/apps/installer-ui/vite.config.ts b/desktop/apps/installer-ui/vite.config.ts new file mode 100644 index 0000000..425c778 --- /dev/null +++ b/desktop/apps/installer-ui/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { outDir: "dist" }, +}); diff --git a/desktop/apps/updater-ui/index.html b/desktop/apps/updater-ui/index.html new file mode 100644 index 0000000..ce34b60 --- /dev/null +++ b/desktop/apps/updater-ui/index.html @@ -0,0 +1,11 @@ + + + + + clawtool + + +
        + + + diff --git a/desktop/apps/updater-ui/package.json b/desktop/apps/updater-ui/package.json new file mode 100644 index 0000000..1b1a77e --- /dev/null +++ b/desktop/apps/updater-ui/package.json @@ -0,0 +1,23 @@ +{ + "name": "@clawtool/updater-ui", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@clawtool/bridge": "workspace:*", + "@clawtool/design-system": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.7" + } +} diff --git a/desktop/apps/updater-ui/src/App.tsx b/desktop/apps/updater-ui/src/App.tsx new file mode 100644 index 0000000..7aece9b --- /dev/null +++ b/desktop/apps/updater-ui/src/App.tsx @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; +import { ProgressBar, Wordmark } from "@clawtool/design-system"; +import { on } from "@clawtool/bridge"; +import styles from "./Updater.module.css"; + +type Status = { message?: string; pct?: number }; + +export function App() { + const [message, setMessage] = useState("Checking for updates…"); + const [pct, setPct] = useState(null); + + useEffect(() => { + return on("update:status", (s) => { + if (s?.message) setMessage(s.message); + if (typeof s?.pct === "number") setPct(s.pct); + }); + }, []); + + return ( +
        + +
        {message}
        + {pct === null ?
        :
        } +
        + ); +} diff --git a/desktop/apps/updater-ui/src/Updater.module.css b/desktop/apps/updater-ui/src/Updater.module.css new file mode 100644 index 0000000..7d3bee7 --- /dev/null +++ b/desktop/apps/updater-ui/src/Updater.module.css @@ -0,0 +1,20 @@ +.splash { + height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 28px; +} +.status { color: var(--text-secondary); font-size: 13px; margin-top: 6px; min-height: 18px; } +.bar { width: 100%; max-width: 280px; margin-top: 12px; } +.spinner { + width: 20px; + height: 20px; + margin-top: 14px; + border: 2.4px solid var(--hairline); + border-top-color: var(--accent); + border-radius: 50%; + animation: ct-spin 0.8s linear infinite; +} diff --git a/desktop/apps/updater-ui/src/main.tsx b/desktop/apps/updater-ui/src/main.tsx new file mode 100644 index 0000000..31cb538 --- /dev/null +++ b/desktop/apps/updater-ui/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import "@clawtool/design-system/global.css"; +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/desktop/apps/updater-ui/src/vite-env.d.ts b/desktop/apps/updater-ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/desktop/apps/updater-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/desktop/apps/updater-ui/tsconfig.json b/desktop/apps/updater-ui/tsconfig.json new file mode 100644 index 0000000..fe31a3a --- /dev/null +++ b/desktop/apps/updater-ui/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/desktop/apps/updater-ui/vite.config.ts b/desktop/apps/updater-ui/vite.config.ts new file mode 100644 index 0000000..425c778 --- /dev/null +++ b/desktop/apps/updater-ui/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + build: { outDir: "dist" }, +}); diff --git a/desktop/packages/design-system/src/components/LogFeed.module.css b/desktop/packages/design-system/src/components/LogFeed.module.css new file mode 100644 index 0000000..52157f1 --- /dev/null +++ b/desktop/packages/design-system/src/components/LogFeed.module.css @@ -0,0 +1,26 @@ +.well { + flex: 1; + min-height: 0; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-md); + overflow: hidden; + display: flex; + flex-direction: column; +} +.head { + font: 500 10.5px / 1 var(--font-mono); + color: var(--text-tertiary); + letter-spacing: 0.05em; + text-transform: uppercase; + padding: 7px 12px; + border-bottom: 1px solid var(--hairline); +} +.feed { flex: 1; overflow-y: auto; padding: 8px 12px 10px; font: 12px/1.65 var(--font-mono); } +.line { display: flex; gap: 9px; } +.time { color: var(--text-tertiary); flex: none; } +.msg { color: var(--text-secondary); white-space: pre-wrap; } +.dim { color: var(--text-tertiary); } +.ok .msg { color: var(--text); } +.done .msg { color: var(--success); } +.warn .msg { color: var(--warning); } diff --git a/desktop/packages/design-system/src/components/LogFeed.tsx b/desktop/packages/design-system/src/components/LogFeed.tsx new file mode 100644 index 0000000..62ecb3b --- /dev/null +++ b/desktop/packages/design-system/src/components/LogFeed.tsx @@ -0,0 +1,29 @@ +import { useEffect, useRef } from "react"; +import styles from "./LogFeed.module.css"; + +export type LogLine = { time: string; label: string; detail?: string; tone?: "ok" | "done" | "warn" }; + +export function LogFeed({ title = "install log", lines }: { title?: string; lines: LogLine[] }) { + const feedRef = useRef(null); + useEffect(() => { + const el = feedRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [lines]); + + return ( +
        +
        {title}
        +
        + {lines.map((l, i) => ( +
        + {l.time} + + {l.label} + {l.detail ? {l.detail} : null} + +
        + ))} +
        +
        + ); +} diff --git a/desktop/packages/design-system/src/components/ProgressBar.module.css b/desktop/packages/design-system/src/components/ProgressBar.module.css new file mode 100644 index 0000000..f70aae9 --- /dev/null +++ b/desktop/packages/design-system/src/components/ProgressBar.module.css @@ -0,0 +1,13 @@ +.track { + height: 3px; + border-radius: 3px; + background: var(--hairline); + overflow: hidden; +} +.fill { + display: block; + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.45s cubic-bezier(0.22, 1, 0.36, 1); +} diff --git a/desktop/packages/design-system/src/components/ProgressBar.tsx b/desktop/packages/design-system/src/components/ProgressBar.tsx new file mode 100644 index 0000000..0442150 --- /dev/null +++ b/desktop/packages/design-system/src/components/ProgressBar.tsx @@ -0,0 +1,10 @@ +import styles from "./ProgressBar.module.css"; + +export function ProgressBar({ value }: { value: number }) { + const pct = Math.max(0, Math.min(100, value)); + return ( +
        + +
        + ); +} diff --git a/desktop/packages/design-system/src/index.ts b/desktop/packages/design-system/src/index.ts index 6166afc..0fe3086 100644 --- a/desktop/packages/design-system/src/index.ts +++ b/desktop/packages/design-system/src/index.ts @@ -9,4 +9,6 @@ export { TitleBar, type WindowControls } from "./components/TitleBar"; export { Sidebar, NavItem } from "./components/Sidebar"; export { Switch } from "./components/Switch"; export { KeyValue, KVRow } from "./components/KeyValue"; +export { ProgressBar } from "./components/ProgressBar"; +export { LogFeed, type LogLine } from "./components/LogFeed"; export { getPlatform, isMac, type Platform } from "./lib/platform"; diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index f018b00..5298c31 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -71,6 +71,68 @@ importers: specifier: ^6.0.7 version: 6.4.2 + apps/installer-ui: + dependencies: + '@clawtool/bridge': + specifier: workspace:* + version: link:../../packages/bridge + '@clawtool/design-system': + specifier: workspace:* + version: link:../../packages/design-system + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.12 + version: 18.3.29 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.29) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.2) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.2 + + apps/updater-ui: + dependencies: + '@clawtool/bridge': + specifier: workspace:* + version: link:../../packages/bridge + '@clawtool/design-system': + specifier: workspace:* + version: link:../../packages/design-system + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.12 + version: 18.3.29 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.29) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.2) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.2 + packages/bridge: devDependencies: typescript: From 0965b0965a616933360bd0c8cbb8fe6cb783e367 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 04:38:27 +0300 Subject: [PATCH 17/86] feat(desktop): ship the React SPA on the Wails binary + frameless chrome Replace the hand-written vanilla index.html with the Vite-built React SPA (app-ui) embedded in the proven Wails binary. The single bundle routes on App.Mode(): setup -> installer surface, installer (first run) -> onboarding init, app -> the main app. Compose the installer surface into app-ui via a workspace dep so one bundle serves every mode. Make the window frameless (custom titlebar drives drag + window controls; macOS keeps inset traffic lights via TitleBarHiddenInset). CI builds the pnpm monorepo and places the bundle into the Go embed dir before the existing 2-build payload flow. The physical 3-process split (separate updater/app exes) is staged on the same surfaces but deferred until a Windows smoke-test, to avoid shipping an untested install pipeline. --- .github/workflows/installer.yml | 25 + .../frontend/dist/.gitignore | 3 + cmd/clawtool-installer/frontend/dist/.gitkeep | 0 .../frontend/dist/index.html | 846 ------------------ cmd/clawtool-installer/main.go | 15 +- desktop/apps/app-ui/package.json | 3 +- desktop/apps/app-ui/src/Init.module.css | 6 + desktop/apps/app-ui/src/InitSurface.tsx | 72 ++ desktop/apps/app-ui/src/Root.tsx | 35 + desktop/apps/app-ui/src/main.tsx | 4 +- desktop/apps/installer-ui/package.json | 3 + desktop/package.json | 1 + .../design-system/src/components/TitleBar.tsx | 4 +- desktop/pnpm-lock.yaml | 3 + 14 files changed, 167 insertions(+), 853 deletions(-) create mode 100644 cmd/clawtool-installer/frontend/dist/.gitignore create mode 100644 cmd/clawtool-installer/frontend/dist/.gitkeep delete mode 100644 cmd/clawtool-installer/frontend/dist/index.html create mode 100644 desktop/apps/app-ui/src/Init.module.css create mode 100644 desktop/apps/app-ui/src/InitSurface.tsx create mode 100644 desktop/apps/app-ui/src/Root.tsx diff --git a/.github/workflows/installer.yml b/.github/workflows/installer.yml index 576dde8..4c433a4 100644 --- a/.github/workflows/installer.yml +++ b/.github/workflows/installer.yml @@ -86,6 +86,31 @@ jobs: - name: wails doctor run: wails doctor || true + # Build the React frontend monorepo (Vite) and place the app SPA's + # bundle where the Go binary embeds it (//go:embed all:frontend/dist). + # The same SPA serves every mode (app / installer / setup) by routing on + # App.Mode(), so one bundle is embedded by both the app and setup builds. + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Build desktop frontend (Vite) + working-directory: ${{ github.workspace }}/desktop + run: | + corepack enable + pnpm install --frozen-lockfile + pnpm --filter @clawtool/app-ui build + shell: bash + + - name: Place frontend bundle into the Go embed dir + working-directory: ${{ github.workspace }} + run: | + rm -rf cmd/clawtool-installer/frontend/dist + mkdir -p cmd/clawtool-installer/frontend/dist + cp -R desktop/apps/app-ui/dist/. cmd/clawtool-installer/frontend/dist/ + shell: bash + # ── Windows: custom app-style installer via a 2-build flow ────── # No classic NSIS wizard. The same Wails binary is both the app and # the setup — payload presence (//go:embed all:payload) flips it diff --git a/cmd/clawtool-installer/frontend/dist/.gitignore b/cmd/clawtool-installer/frontend/dist/.gitignore new file mode 100644 index 0000000..c3d07e5 --- /dev/null +++ b/cmd/clawtool-installer/frontend/dist/.gitignore @@ -0,0 +1,3 @@ +* +!.gitkeep +!.gitignore diff --git a/cmd/clawtool-installer/frontend/dist/.gitkeep b/cmd/clawtool-installer/frontend/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/cmd/clawtool-installer/frontend/dist/index.html b/cmd/clawtool-installer/frontend/dist/index.html deleted file mode 100644 index 36265fd..0000000 --- a/cmd/clawtool-installer/frontend/dist/index.html +++ /dev/null @@ -1,846 +0,0 @@ - - - - - - clawtool - - - - -
        -

        clawtool

        -
        -
        Checking for updates…
        -
        - - -
        -
        clawtool.first launch
        -

        Setting up clawtool

        -
        Bringing your gateway online…
        -
        0%
        -
          - -
          - - -
          -
          - - -
          -
          -

          Install

          -

          -
          -
          - - The command, added to your PATH. -
          -
          - - A desktop app that runs in your tray as the gateway. -
          -
          - - Quiet self-updates — no admin prompts. -
          -
          -
          -
          - - -
          -
          - - -
          -

          Installing

          step 2 / 3
          -
          -
          Starting…0%
          -
          -
          install log
          -
          -
          -
          - - -
          -
          - - installed -
          -
          -
          - -
          -

          is ready

          -

          Installed to . Open a new terminal to use the command.

          -
          - - -
          -
          -
          - - -
          - -
          -
          -
          - -
          -

          clawtool is running

          -
          This device is a gateway on your network
          -
          -
          -
          -
          Local agents
          -
          LAN peers
          -
          Cross-device
          -
          -

          Agents on this device

          -
          -
          - Closing the window keeps clawtool running in the menu bar. - -
          -
          -
          - -

          This device

          -
          Agents running locally on this machine.
          -
          -

          Cross-device

          -
          -

          Network

          -
          -
          -
          -

          Updates

          -
          clawtool checks for a new version each time it launches.
          -
          -
          Installed
          -
          Latest
          -
          Statuschecking…
          -
          -
          - - -
          -
          -
          -
          - - - - diff --git a/cmd/clawtool-installer/main.go b/cmd/clawtool-installer/main.go index 0e66e54..93d606b 100644 --- a/cmd/clawtool-installer/main.go +++ b/cmd/clawtool-installer/main.go @@ -21,6 +21,7 @@ import ( "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/mac" ) // assets embeds the splash frontend. `wails build` populates/uses @@ -58,9 +59,17 @@ func main() { } err := wails.Run(&options.App{ - Title: "clawtool", - Width: 520, - Height: 420, + Title: "clawtool", + // Frameless: the OS title bar is dropped in favour of our own custom + // titlebar (drag region + window controls) rendered by the frontend, + // so the chrome is consistent across Windows and macOS. On macOS we + // keep the native inset traffic lights (top-left) and reserve a gutter + // for them; on Windows the frontend draws min/maximize/close on the + // right. + Frameless: true, + Mac: &mac.Options{TitleBar: mac.TitleBarHiddenInset()}, + Width: 520, + Height: 420, // The window opens as a small, fixed Discord-style splash — the // Updater phase (every launch) and, on first run, the // Initializing phase render here. Once those finish, the frontend diff --git a/desktop/apps/app-ui/package.json b/desktop/apps/app-ui/package.json index 5be3039..043b21f 100644 --- a/desktop/apps/app-ui/package.json +++ b/desktop/apps/app-ui/package.json @@ -11,7 +11,8 @@ "@clawtool/bridge": "workspace:*", "@clawtool/design-system": "workspace:*", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "@clawtool/installer-ui": "workspace:*" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/desktop/apps/app-ui/src/Init.module.css b/desktop/apps/app-ui/src/Init.module.css new file mode 100644 index 0000000..36e05e3 --- /dev/null +++ b/desktop/apps/app-ui/src/Init.module.css @@ -0,0 +1,6 @@ +.shell { display: flex; flex-direction: column; height: 100vh; } +.body { flex: 1; min-height: 0; display: flex; flex-direction: column; padding: 26px 30px 22px; } +.top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; } +.title { font-size: var(--size-xl); font-weight: 600; letter-spacing: -0.02em; } +.sub { color: var(--text-secondary); font-size: 13px; margin-top: 2px; } +.bar { margin: 14px 0; } diff --git a/desktop/apps/app-ui/src/InitSurface.tsx b/desktop/apps/app-ui/src/InitSurface.tsx new file mode 100644 index 0000000..8639593 --- /dev/null +++ b/desktop/apps/app-ui/src/InitSurface.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState } from "react"; +import { Badge, LogFeed, ProgressBar, TitleBar, Wordmark, type LogLine, type Platform } from "@clawtool/design-system"; +import { App as Backend, Win, environmentPlatform, on, type Brand, type InstallDone, type InstallStep } from "@clawtool/bridge"; +import styles from "./Init.module.css"; + +function clock(): string { + const d = new Date(); + return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":"); +} + +// First-launch onboarding: runs the one-time initialize flow (daemon, bridges, +// agent claim) with a branded, live progress view, then hands off to the app. +export function InitSurface({ onDone }: { onDone: () => void }) { + const [platform, setPlatform] = useState("windows"); + const [brand, setBrand] = useState({ name: "clawtool", cli: "clawtool", tagline: "", installDir: "", version: "" }); + const [lines, setLines] = useState([]); + const [pct, setPct] = useState(4); + const [ready, setReady] = useState(false); + const pctRef = useRef(4); + const doneRef = useRef(false); + + function bump(to: number) { + pctRef.current = Math.max(pctRef.current, Math.min(to, 100)); + setPct(pctRef.current); + } + + useEffect(() => { + environmentPlatform().then((p) => setPlatform(p === "web" ? "windows" : p)); + Backend.brand().then(setBrand); + let n = 0; + const offStep = on("install:step", (s) => { + if (doneRef.current || !s?.label) return; + const tone = s.level === "warn" ? "warn" : s.level === "fail" ? "warn" : "ok"; + setLines((prev) => [...prev, { time: clock(), label: s.label!, detail: s.message, tone }]); + n += 1; + bump(8 + Math.min(86, n * 11)); + }); + const offDone = on("install:done", (d) => { + doneRef.current = true; + bump(100); + if (d?.ok) { + setReady(true); + setTimeout(onDone, 1100); + } + }); + Backend.install(); + return () => { + offStep(); + offDone(); + }; + }, [onDone]); + + const controls = { onMinimize: () => Win.minimise(), onToggleMaximize: () => Win.toggleMaximise(), onClose: () => Win.quit() }; + + return ( +
          + +
          +
          + + first launch +
          +

          {ready ? `${brand.name} is ready` : `Setting up ${brand.name}`}

          +
          {ready ? "Your gateway is live on this device." : "Bringing your gateway online…"}
          +
          + +
          + +
          +
          + ); +} diff --git a/desktop/apps/app-ui/src/Root.tsx b/desktop/apps/app-ui/src/Root.tsx new file mode 100644 index 0000000..309eb01 --- /dev/null +++ b/desktop/apps/app-ui/src/Root.tsx @@ -0,0 +1,35 @@ +import { useEffect, useState } from "react"; +import { App as Backend } from "@clawtool/bridge"; +import { App as InstallerSurface } from "@clawtool/installer-ui"; +import { App } from "./App"; +import { InitSurface } from "./InitSurface"; + +type Route = "loading" | "setup" | "init" | "app"; + +// The single shipping SPA routes on the Go-decided mode: +// setup -> the installer flow (self-install) +// installer -> first-launch onboarding (one-time init) if not yet initialized +// app -> the main app +// (The physical 3-binary split reuses these same surfaces, one per binary.) +export function Root() { + const [route, setRoute] = useState("loading"); + + function enterApp() { + Backend.enterApp(); // grow the window from the fixed splash to the full app + setRoute("app"); + } + + useEffect(() => { + (async () => { + const mode = await Backend.mode(); + if (mode === "setup") return setRoute("setup"); + if (mode === "installer" && !(await Backend.isInitialized())) return setRoute("init"); + enterApp(); + })(); + }, []); + + if (route === "loading") return null; + if (route === "setup") return ; + if (route === "init") return ; + return ; +} diff --git a/desktop/apps/app-ui/src/main.tsx b/desktop/apps/app-ui/src/main.tsx index 31cb538..9ef557d 100644 --- a/desktop/apps/app-ui/src/main.tsx +++ b/desktop/apps/app-ui/src/main.tsx @@ -1,10 +1,10 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import "@clawtool/design-system/global.css"; -import { App } from "./App"; +import { Root } from "./Root"; createRoot(document.getElementById("root")!).render( - + , ); diff --git a/desktop/apps/installer-ui/package.json b/desktop/apps/installer-ui/package.json index 322c88b..97d2988 100644 --- a/desktop/apps/installer-ui/package.json +++ b/desktop/apps/installer-ui/package.json @@ -2,6 +2,9 @@ "name": "@clawtool/installer-ui", "private": true, "type": "module", + "exports": { + ".": "./src/App.tsx" + }, "scripts": { "dev": "vite", "build": "vite build", diff --git a/desktop/package.json b/desktop/package.json index cb81c61..50a996e 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -2,6 +2,7 @@ "name": "clawtool-desktop", "private": true, "type": "module", + "packageManager": "pnpm@10.33.0", "scripts": { "typecheck": "pnpm -r typecheck", "build": "pnpm -r build" diff --git a/desktop/packages/design-system/src/components/TitleBar.tsx b/desktop/packages/design-system/src/components/TitleBar.tsx index 715ba19..c9a2d1a 100644 --- a/desktop/packages/design-system/src/components/TitleBar.tsx +++ b/desktop/packages/design-system/src/components/TitleBar.tsx @@ -38,7 +38,9 @@ export function TitleBar({ style={{ "--wails-draggable": "drag" } as CSSProperties} onDoubleClick={controls.onToggleMaximize} > - {onMac ? buttons : null} + {/* macOS keeps its native inset traffic lights (top-left): reserve a + gutter and render no custom buttons. Windows/Linux: custom controls + on the right. */} {onMac ?
          : null}
          {center}
          {onMac ? null : buttons} diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 5298c31..0f6a07a 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@clawtool/design-system': specifier: workspace:* version: link:../../packages/design-system + '@clawtool/installer-ui': + specifier: workspace:* + version: link:../installer-ui react: specifier: ^18.3.1 version: 18.3.1 From 1fd60a80b636303bee0b44fd2e0b7398661c6ebb Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 04:45:37 +0300 Subject: [PATCH 18/86] docs(installer): update stale 'vanilla frontend' notes for the React SPA --- cmd/clawtool-installer/main.go | 9 +++++---- cmd/clawtool-installer/wails.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/clawtool-installer/main.go b/cmd/clawtool-installer/main.go index 93d606b..cd4f48f 100644 --- a/cmd/clawtool-installer/main.go +++ b/cmd/clawtool-installer/main.go @@ -10,7 +10,7 @@ // Wails toolchain + platform webview libs to build — it is NOT // compiled by the main repo CI. Build + verify with `wails build` // (or `wails dev`) on a Windows / macOS host. The Go here is -// gofmt-clean and follows the Wails v2 vanilla template; treat any +// gofmt-clean and follows the Wails v2 conventions; treat any // build error as a version-pin / API-drift fix, not a redesign. package main @@ -24,9 +24,10 @@ import ( "github.com/wailsapp/wails/v2/pkg/options/mac" ) -// assets embeds the splash frontend. `wails build` populates/uses -// frontend/dist; the vanilla (no-bundler) setup keeps the static -// files directly under frontend/dist. +// assets embeds the built React SPA (the desktop/ pnpm monorepo's app-ui, +// Vite-built). CI runs `pnpm build` and copies the bundle into frontend/dist +// before the Go build; locally, do the same. The single SPA routes on +// App.Mode() to the app / installer / first-launch surfaces. // //go:embed all:frontend/dist var assets embed.FS diff --git a/cmd/clawtool-installer/wails.json b/cmd/clawtool-installer/wails.json index 0e3996b..0e4670a 100644 --- a/cmd/clawtool-installer/wails.json +++ b/cmd/clawtool-installer/wails.json @@ -14,6 +14,6 @@ "companyName": "Cogitave", "productName": "clawtool", "copyright": "Copyright © Cogitave", - "comments": "clawtool desktop app — vanilla (no-bundler) frontend; static files live under frontend/dist and are embedded directly." + "comments": "clawtool desktop app — React SPA (desktop/ pnpm monorepo, Vite-built) embedded from frontend/dist; CI builds the bundle and places it there before the Go build." } } From a2072902405a38ddd7aded63ef463929dfb348d1 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 23:10:22 +0300 Subject: [PATCH 19/86] feat(desktop): Agents tab, agent icons, fix circle copy/join, connect affordances - Fix circle key: auto-copy on generate + a Copy button + toast (was uncopyable). - Implement "Join with a key" (was a dead button): reveal input -> circleSet. - Per-family AgentIcon (claude/codex/gemini/opencode/hermes, brand-tinted monograms). - New Agents tab: per-agent rows with icon + family/bridge/tags/sandbox, Connect (claim) / Disconnect (release) actions, and this device's A2A card (name/version/ url/skills). Go bindings AgentClaim/AgentRelease/LocalCard added. - Install/connect affordances: bridge-missing agents show "Install bridge"/"Connect" instead of a dead row; empty states route to the Agents tab. - Network view now focuses on cross-device (circle/LAN) + devices. --- cmd/clawtool-installer/app.go | 45 ++++++ desktop/apps/app-ui/src/App.module.css | 23 +++ desktop/apps/app-ui/src/App.tsx | 7 +- .../app-ui/src/components/Toast.module.css | 20 +++ desktop/apps/app-ui/src/components/Toast.tsx | 11 ++ desktop/apps/app-ui/src/icons.tsx | 9 ++ desktop/apps/app-ui/src/views/Agents.tsx | 112 +++++++++++++ desktop/apps/app-ui/src/views/Home.tsx | 8 +- desktop/apps/app-ui/src/views/Network.tsx | 150 +++++++++++------- desktop/packages/bridge/src/app.ts | 6 + desktop/packages/bridge/src/types.ts | 14 ++ .../src/components/AgentIcon.module.css | 10 ++ .../src/components/AgentIcon.tsx | 28 ++++ .../src/components/AgentRow.module.css | 1 + .../design-system/src/components/AgentRow.tsx | 27 +++- desktop/packages/design-system/src/index.ts | 1 + 16 files changed, 407 insertions(+), 65 deletions(-) create mode 100644 desktop/apps/app-ui/src/components/Toast.module.css create mode 100644 desktop/apps/app-ui/src/components/Toast.tsx create mode 100644 desktop/apps/app-ui/src/views/Agents.tsx create mode 100644 desktop/packages/design-system/src/components/AgentIcon.module.css create mode 100644 desktop/packages/design-system/src/components/AgentIcon.tsx diff --git a/cmd/clawtool-installer/app.go b/cmd/clawtool-installer/app.go index f64161e..3c96e73 100644 --- a/cmd/clawtool-installer/app.go +++ b/cmd/clawtool-installer/app.go @@ -670,6 +670,51 @@ func (a *App) lanSet(mode string) string { return `{"ok":true}` } +// AgentClaim registers clawtool with an agent host (e.g. claude / codex) so +// the agent can call clawtool's tools — `clawtool agents claim `. This +// is the "Connect" action that resolves a bridge-missing agent. +func (a *App) AgentClaim(name string) string { return a.agentAction("claim", name) } + +// AgentRelease unregisters clawtool from an agent host — +// `clawtool agents release ` (the "Disconnect" action). +func (a *App) AgentRelease(name string) string { return a.agentAction("release", name) } + +func (a *App) agentAction(action, name string) string { + name = strings.TrimSpace(name) + if name == "" { + return jsonErr("missing agent name") + } + bin, err := locateClawtool() + if err != nil { + return jsonErr(err.Error()) + } + cmd := exec.Command(bin, "agents", action, name) + hideConsole(cmd) + if out, err := cmd.CombinedOutput(); err != nil { + msg := firstLine(out) + if msg == "" { + msg = "could not " + action + " " + name + } + return jsonErr(msg) + } + return `{"ok":true}` +} + +// LocalCard returns this device's public A2A Agent Card +// (/.well-known/agent-card.json) — capability/skill metadata, no secrets — +// for the Agents tab to display. Raw daemon body on success. +func (a *App) LocalCard() string { + base, err := a.ensureDaemonBase() + if err != nil { + return jsonErr(err.Error()) + } + body, err := httpGetJSON(base + "/.well-known/agent-card.json") + if err != nil { + return jsonErr(err.Error()) + } + return string(body) +} + // ensureDaemonBase resolves the daemon's loopback base URL, starting the // daemon if it isn't recorded yet. func (a *App) ensureDaemonBase() (string, error) { diff --git a/desktop/apps/app-ui/src/App.module.css b/desktop/apps/app-ui/src/App.module.css index 0a1faf1..90e223f 100644 --- a/desktop/apps/app-ui/src/App.module.css +++ b/desktop/apps/app-ui/src/App.module.css @@ -43,3 +43,26 @@ .switchRow .st { font-weight: 600; font-size: 13.5px; } .switchRow .sd { color: var(--text-secondary); font-size: 12.5px; margin-top: 3px; line-height: 1.45; } .actions { display: flex; gap: 18px; margin-top: 18px; } +.input { + font: inherit; + font-size: 13px; + width: 100%; + padding: 9px 12px; + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); +} +.input:focus { outline: none; border-color: var(--accent); } +.err { color: var(--warning); font-size: 12.5px; margin-top: 8px; } +.linklike { background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer; font: inherit; font-size: var(--size-md); } +.linklike:hover { text-decoration: underline; } +.keybox { + font: 500 12.5px var(--font-mono); + color: var(--text); + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + padding: 7px 11px; + letter-spacing: 0.02em; +} diff --git a/desktop/apps/app-ui/src/App.tsx b/desktop/apps/app-ui/src/App.tsx index a6badd6..577a3a5 100644 --- a/desktop/apps/app-ui/src/App.tsx +++ b/desktop/apps/app-ui/src/App.tsx @@ -1,13 +1,14 @@ import { useEffect, useState } from "react"; import { NavItem, Sidebar, StatusDot, TitleBar, Wordmark, type Platform } from "@clawtool/design-system"; import { App as Backend, Win, environmentPlatform, type Brand } from "@clawtool/bridge"; -import { HomeIcon, NetworkIcon, UpdatesIcon } from "./icons"; +import { AgentsIcon, HomeIcon, NetworkIcon, UpdatesIcon } from "./icons"; import { Home } from "./views/Home"; +import { Agents } from "./views/Agents"; import { Network } from "./views/Network"; import { Updates } from "./views/Updates"; import styles from "./App.module.css"; -type View = "home" | "network" | "updates"; +type View = "home" | "agents" | "network" | "updates"; export function App() { const [platform, setPlatform] = useState("windows"); @@ -40,11 +41,13 @@ export function App() { } > } label="Home" active={view === "home"} onClick={() => setView("home")} /> + } label="Agents" active={view === "agents"} onClick={() => setView("agents")} /> } label="Network" active={view === "network"} onClick={() => setView("network")} /> } label="Updates" active={view === "updates"} onClick={() => setView("updates")} />
          {view === "home" ? setView(v as View)} /> : null} + {view === "agents" ? : null} {view === "network" ? : null} {view === "updates" ? : null}
          diff --git a/desktop/apps/app-ui/src/components/Toast.module.css b/desktop/apps/app-ui/src/components/Toast.module.css new file mode 100644 index 0000000..6b80748 --- /dev/null +++ b/desktop/apps/app-ui/src/components/Toast.module.css @@ -0,0 +1,20 @@ +.toast { + position: fixed; + bottom: 22px; + left: 50%; + transform: translateX(-50%) translateY(12px); + background: var(--text); + color: var(--bg); + font-size: 12.5px; + font-weight: 500; + padding: 8px 16px; + border-radius: var(--radius-pill); + opacity: 0; + transition: opacity 0.2s, transform 0.2s; + pointer-events: none; + z-index: 50; +} +.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} diff --git a/desktop/apps/app-ui/src/components/Toast.tsx b/desktop/apps/app-ui/src/components/Toast.tsx new file mode 100644 index 0000000..687712b --- /dev/null +++ b/desktop/apps/app-ui/src/components/Toast.tsx @@ -0,0 +1,11 @@ +import { useEffect } from "react"; +import styles from "./Toast.module.css"; + +export function Toast({ message, onClear }: { message: string; onClear: () => void }) { + useEffect(() => { + if (!message) return; + const t = setTimeout(onClear, 1900); + return () => clearTimeout(t); + }, [message, onClear]); + return
          {message}
          ; +} diff --git a/desktop/apps/app-ui/src/icons.tsx b/desktop/apps/app-ui/src/icons.tsx index 9081a8a..6a85827 100644 --- a/desktop/apps/app-ui/src/icons.tsx +++ b/desktop/apps/app-ui/src/icons.tsx @@ -31,3 +31,12 @@ export const UpdatesIcon = () => ( ); + +export const AgentsIcon = () => ( + + + + + + +); diff --git a/desktop/apps/app-ui/src/views/Agents.tsx b/desktop/apps/app-ui/src/views/Agents.tsx new file mode 100644 index 0000000..1f37158 --- /dev/null +++ b/desktop/apps/app-ui/src/views/Agents.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from "react"; +import { AgentRow, Badge, Button, EmptyRow, KVRow, KeyValue, List, SectionHeader } from "@clawtool/design-system"; +import { App, type Agent, type AgentCard, type Brand } from "@clawtool/bridge"; +import { Toast } from "../components/Toast"; +import styles from "../App.module.css"; + +function metaFor(a: Agent): string { + const parts = [a.family]; + if (a.bridge) parts.push(a.bridge); + if (a.tags && a.tags.length) parts.push(a.tags.join(", ")); + if (a.sandbox) parts.push(`sandbox: ${a.sandbox}`); + return parts.join(" · "); +} + +export function Agents({ brand }: { brand: Brand }) { + const [agents, setAgents] = useState([]); + const [card, setCard] = useState(null); + const [busy, setBusy] = useState(""); + const [toast, setToast] = useState(""); + + async function load() { + const snap = await App.networkSnapshot(); + if (snap.ok === true) setAgents(snap.agents?.agents ?? []); + else App.ensureGateway(); + const c = await App.localCard(); + setCard(c && (c.name || c.skills) ? c : null); + } + + useEffect(() => { + load(); + }, []); + + async function connect(a: Agent) { + setBusy(a.instance); + const r = await App.agentClaim(a.instance); + setToast(r.ok ? `Connected ${a.instance}` : typeof r.error === "string" ? r.error : `Couldn't connect ${a.instance}`); + await load(); + setBusy(""); + } + async function disconnect(a: Agent) { + setBusy(a.instance); + const r = await App.agentRelease(a.instance); + setToast(r.ok ? `Disconnected ${a.instance}` : typeof r.error === "string" ? r.error : `Couldn't disconnect ${a.instance}`); + await load(); + setBusy(""); + } + + function actionFor(a: Agent) { + const b = busy === a.instance; + if (a.callable) { + return ( + + ); + } + // bridge-missing / binary-missing → the install/connect affordance + return ( + + ); + } + + return ( + <> +

          Agents

          +
          AI agents on this device that can call {brand.name}'s tools.
          + + + + {agents.length ? ( + agents.map((a) => ( + + )) + ) : ( + + No agents detected. Install Claude Code, Codex, or Gemini — then they appear here to connect. Or run{" "} + {brand.cli} onboard. + + )} + + + + {card ? ( + <> + + {card.name ? {card.name} : null} + {card.version ? {String(card.version)} : null} + {card.url ? {card.url} : null} + {card.skills?.length ?? 0} + + {card.skills && card.skills.length ? ( +
          + {card.skills.map((s, i) => ( + + {s.name ?? s.id ?? "skill"} + + ))} +
          + ) : null} + + ) : ( + + The A2A card is published once the gateway is running and circle peering is on. + + )} + + setToast("")} /> + + ); +} diff --git a/desktop/apps/app-ui/src/views/Home.tsx b/desktop/apps/app-ui/src/views/Home.tsx index 6a7a2b5..7a9a823 100644 --- a/desktop/apps/app-ui/src/views/Home.tsx +++ b/desktop/apps/app-ui/src/views/Home.tsx @@ -57,13 +57,14 @@ export function Home({ brand, onNavigate }: { brand: Brand; onNavigate: (v: stri
          - onNavigate("network")}>Network →} /> + onNavigate("agents")}>Manage →} /> {agents.length ? ( agents.map((a) => ( - No agents here yet — run {brand.cli} onboard to connect one. + No agents connected yet.{" "} + )} diff --git a/desktop/apps/app-ui/src/views/Network.tsx b/desktop/apps/app-ui/src/views/Network.tsx index 5e357b9..00e3c70 100644 --- a/desktop/apps/app-ui/src/views/Network.tsx +++ b/desktop/apps/app-ui/src/views/Network.tsx @@ -1,30 +1,33 @@ import { useEffect, useState } from "react"; -import { - AgentRow, - Badge, - Button, - EmptyRow, - KVRow, - KeyValue, - List, - SectionHeader, - Switch, -} from "@clawtool/design-system"; -import { App, type Agent, type Brand, type Peer } from "@clawtool/bridge"; +import { Badge, Button, EmptyRow, KVRow, KeyValue, List, SectionHeader, Switch } from "@clawtool/design-system"; +import { App, type Brand, type Peer } from "@clawtool/bridge"; +import { Toast } from "../components/Toast"; import styles from "../App.module.css"; function maskKey(k: string): string { return k && k.length > 14 ? `${k.slice(0, 8)}…${k.slice(-4)}` : k; } +async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + export function Network({ brand }: { brand: Brand }) { const [banner, setBanner] = useState(""); - const [agents, setAgents] = useState([]); const [peers, setPeers] = useState([]); const [hasKey, setHasKey] = useState(false); const [key, setKey] = useState(""); const [lan, setLan] = useState(false); const [lanBusy, setLanBusy] = useState(false); + const [joinOpen, setJoinOpen] = useState(false); + const [joinKey, setJoinKey] = useState(""); + const [joinErr, setJoinErr] = useState(""); + const [toast, setToast] = useState(""); async function loadNetwork() { const snap = await App.networkSnapshot(); @@ -34,7 +37,6 @@ export function Network({ brand }: { brand: Brand }) { return; } setBanner(""); - setAgents(snap.agents?.agents ?? []); setPeers(snap.peers?.peers ?? []); } async function loadCircle() { @@ -52,6 +54,32 @@ export function Network({ brand }: { brand: Brand }) { return () => clearInterval(t); }, []); + async function generate() { + const r = await App.circleGenerate(); + await loadCircle(); + const k = typeof r.key === "string" ? r.key : ""; + if (r.ok && k) { + const ok = await copyText(k); + setToast(ok ? "Circle key generated and copied" : "Circle key generated"); + } + } + async function copyKey() { + setToast((await copyText(key)) ? "Copied to clipboard" : "Couldn't copy"); + } + async function join() { + setJoinErr(""); + const k = joinKey.trim(); + if (!k) return; + const r = await App.circleSet(k); + if (r.ok) { + setJoinOpen(false); + setJoinKey(""); + await loadCircle(); + setToast("Joined the circle"); + } else { + setJoinErr(typeof r.error === "string" ? r.error : "Couldn't set the circle key"); + } + } async function toggleLan(next: boolean) { setLanBusy(true); await (next ? App.lanEnable() : App.lanDisable()); @@ -62,28 +90,11 @@ export function Network({ brand }: { brand: Brand }) { return ( <> {banner ?
          {banner}
          : null} -

          This device

          -
          Agents running locally on this machine.
          - - {agents.length ? ( - agents.map((a) => ( - - )) - ) : ( - - No agents here yet — run {brand.cli} onboard to connect one. - - )} - +

          Cross-device

          +
          Let your devices see each other's agents over your network.
          - -
          + +
          Cross-device peering {hasKey ? "On" : "Off"} @@ -93,27 +104,56 @@ export function Network({ brand }: { brand: Brand }) { Create a circle to let your devices see each other's agents. Generate a key here, then join the same circle on your other devices.
          -
          - {hasKey ? ( - <> - {maskKey(key)} - + +
          + ) : joinOpen ? ( +
          + setJoinKey(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") join(); + if (e.key === "Escape") setJoinOpen(false); + }} + /> + {joinErr ?
          {joinErr}
          : null} +
          + - - ) : ( - <> - - - - )} -
          +
          +
          + ) : ( +
          + + +
          + )}
          -
          -
          Reachable on your LAN
          +
          +
          + Reachable on your LAN +
          Let circle devices on your network read this device's agent list. Code execution stays local-only; the first time, your OS may ask to allow it through the firewall. @@ -122,7 +162,7 @@ export function Network({ brand }: { brand: Brand }) {
          - + {peers.length ? ( {peers.map((p) => ( @@ -134,11 +174,13 @@ export function Network({ brand }: { brand: Brand }) { ) : ( - No peers discovered yet. Start {brand.name} on another machine on this network — paired devices appear + No devices discovered yet. Start {brand.name} on another machine on this network — paired devices appear automatically over mDNS. )} + + setToast("")} /> ); } diff --git a/desktop/packages/bridge/src/app.ts b/desktop/packages/bridge/src/app.ts index f7355e4..d2dfafa 100644 --- a/desktop/packages/bridge/src/app.ts +++ b/desktop/packages/bridge/src/app.ts @@ -4,6 +4,7 @@ // empty/error state. import { callJSON, callRaw } from "./wails"; import type { + AgentCard, Brand, CircleStatus, LanStatus, @@ -44,6 +45,11 @@ export const App = { checkUpdate: () => callJSON("CheckUpdate", { ok: false }), installUpdate: () => callJSON("InstallUpdate", OK_FALSE), + // agents (connect/disconnect a host + this device's A2A card) + agentClaim: (name: string) => callJSON("AgentClaim", OK_FALSE, name), + agentRelease: (name: string) => callJSON("AgentRelease", OK_FALSE, name), + localCard: () => callJSON("LocalCard", {}), + // cross-device (circle key + LAN) circleStatus: () => callJSON("CircleStatus", { ok: false }), circleGenerate: () => callJSON("CircleGenerate", OK_FALSE), diff --git a/desktop/packages/bridge/src/types.ts b/desktop/packages/bridge/src/types.ts index 17413fd..1b2a974 100644 --- a/desktop/packages/bridge/src/types.ts +++ b/desktop/packages/bridge/src/types.ts @@ -19,6 +19,10 @@ export type Agent = { bridge?: string; status?: string; callable?: boolean; + auth_scope?: string; + tags?: string[]; + failover_to?: string[]; + sandbox?: string; }; export type Peer = { @@ -48,6 +52,16 @@ export type UpdateInfo = { error?: string; }; +export type AgentSkill = { id?: string; name?: string; description?: string }; +export type AgentCard = { + name?: string; + description?: string; + url?: string; + version?: string; + skills?: AgentSkill[]; + [k: string]: unknown; +}; + export type Result = { ok: boolean; error?: string; [k: string]: unknown }; export type CircleStatus = { ok: boolean; has_key?: boolean; key?: string }; export type LanStatus = { ok: boolean; enabled?: boolean }; diff --git a/desktop/packages/design-system/src/components/AgentIcon.module.css b/desktop/packages/design-system/src/components/AgentIcon.module.css new file mode 100644 index 0000000..fb194f3 --- /dev/null +++ b/desktop/packages/design-system/src/components/AgentIcon.module.css @@ -0,0 +1,10 @@ +.icon { + border-radius: 7px; + display: grid; + place-items: center; + flex: none; + font-family: var(--font-mono); + font-weight: 600; + text-transform: uppercase; + line-height: 1; +} diff --git a/desktop/packages/design-system/src/components/AgentIcon.tsx b/desktop/packages/design-system/src/components/AgentIcon.tsx new file mode 100644 index 0000000..c422e89 --- /dev/null +++ b/desktop/packages/design-system/src/components/AgentIcon.tsx @@ -0,0 +1,28 @@ +import styles from "./AgentIcon.module.css"; + +// Per-family tint + monogram. Brand-tinted marks (not exact trademark logos). +const FAMILY: Record = { + claude: { tint: "#d97757", mark: "C" }, + codex: { tint: "#10a37f", mark: "O" }, + gemini: { tint: "#4285f4", mark: "G" }, + opencode: { tint: "#8ab4d8", mark: "oc" }, + hermes: { tint: "#a78bfa", mark: "H" }, +}; + +export function AgentIcon({ family, size = 26 }: { family: string; size?: number }) { + const f = FAMILY[family.toLowerCase()] ?? { tint: "var(--accent)", mark: (family[0] ?? "?").toUpperCase() }; + return ( + + {f.mark} + + ); +} diff --git a/desktop/packages/design-system/src/components/AgentRow.module.css b/desktop/packages/design-system/src/components/AgentRow.module.css index c6cf730..ce62538 100644 --- a/desktop/packages/design-system/src/components/AgentRow.module.css +++ b/desktop/packages/design-system/src/components/AgentRow.module.css @@ -21,6 +21,7 @@ } .name { font-weight: 550; font-size: var(--size-md); } .meta { color: var(--text-secondary); font-size: var(--size-sm); margin-top: 1px; } +.action { margin-left: auto; } .status { margin-left: auto; display: flex; diff --git a/desktop/packages/design-system/src/components/AgentRow.tsx b/desktop/packages/design-system/src/components/AgentRow.tsx index b0cdd7e..2117e0c 100644 --- a/desktop/packages/design-system/src/components/AgentRow.tsx +++ b/desktop/packages/design-system/src/components/AgentRow.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { AgentIcon } from "./AgentIcon"; import { StatusDot, type DotTone } from "./StatusDot"; import styles from "./AgentRow.module.css"; @@ -8,22 +9,34 @@ export function List({ children }: { children: ReactNode }) { export function AgentRow({ name, + family, meta, tone = "online", status, -}: { name: string; meta?: string; tone?: DotTone; status?: string }) { - const initials = name.slice(0, 2); + action, +}: { + name: string; + family?: string; + meta?: string; + tone?: DotTone; + status?: string; + action?: ReactNode; +}) { return (
          - {initials} + {family ? : {name.slice(0, 2)}}
          {name}
          {meta ?
          {meta}
          : null}
          - - - {status ?? tone} - + {action ? ( + {action} + ) : ( + + + {status ?? tone} + + )}
          ); } diff --git a/desktop/packages/design-system/src/index.ts b/desktop/packages/design-system/src/index.ts index 0fe3086..489cd27 100644 --- a/desktop/packages/design-system/src/index.ts +++ b/desktop/packages/design-system/src/index.ts @@ -5,6 +5,7 @@ export { Button } from "./components/Button"; export { Metric, MetricStrip } from "./components/Metric"; export { SectionHeader } from "./components/Section"; export { AgentRow, List, EmptyRow } from "./components/AgentRow"; +export { AgentIcon } from "./components/AgentIcon"; export { TitleBar, type WindowControls } from "./components/TitleBar"; export { Sidebar, NavItem } from "./components/Sidebar"; export { Switch } from "./components/Switch"; From e9f173c8530614e9d6863d1925b83cef61e9663d Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 23:42:54 +0300 Subject: [PATCH 20/86] feat(desktop): real agent logos, A2A side-pane, status chips, real bridge install - Agent logos: real brand marks via Simple Icons where redistribution is permitted (Claude, Gemini); clean brand-tinted fallback for families the licensed library omits (e.g. OpenAI/Codex, which the brand restricts). - Agents tab: per-agent status chips (ready / bridge missing / not installed / disabled) with a 5s live refresh; click a row for a detail SidePane, and a "This device's card" pane shows the A2A card (name/version/url/skills). - Install affordance now actually installs: BridgeAdd Go binding runs `clawtool bridge add ` (the canonical install) instead of a claim that errored on a missing bridge/binary; errors surface verbatim. - Pairing: frame cross-device as generate/enter a pairing key on the same network (copy + Toast already fixed); a true short pairing-code protocol is a daemon follow-up. - New design-system components: SidePane, AgentIcon (Simple Icons-backed). --- cmd/clawtool-installer/app.go | 25 +++ desktop/apps/app-ui/src/App.module.css | 2 +- desktop/apps/app-ui/src/views/Agents.tsx | 198 +++++++++++++----- desktop/apps/app-ui/src/views/Network.tsx | 18 +- desktop/packages/bridge/src/app.ts | 1 + desktop/packages/design-system/package.json | 3 + .../src/components/AgentIcon.tsx | 47 +++-- .../src/components/AgentRow.module.css | 1 + .../design-system/src/components/AgentRow.tsx | 4 +- .../src/components/SidePane.module.css | 36 ++++ .../design-system/src/components/SidePane.tsx | 23 ++ desktop/packages/design-system/src/index.ts | 1 + desktop/pnpm-lock.yaml | 9 + 13 files changed, 288 insertions(+), 80 deletions(-) create mode 100644 desktop/packages/design-system/src/components/SidePane.module.css create mode 100644 desktop/packages/design-system/src/components/SidePane.tsx diff --git a/cmd/clawtool-installer/app.go b/cmd/clawtool-installer/app.go index 3c96e73..bad54b8 100644 --- a/cmd/clawtool-installer/app.go +++ b/cmd/clawtool-installer/app.go @@ -700,6 +700,31 @@ func (a *App) agentAction(action, name string) string { return `{"ok":true}` } +// BridgeAdd installs the canonical bridge for an agent family — +// `clawtool bridge add ` — so a bridge-missing agent becomes +// callable. This is the "Install bridge" action. The error (e.g. the agent +// binary not being on PATH) is surfaced to the UI verbatim. +func (a *App) BridgeAdd(family string) string { + family = strings.TrimSpace(family) + if family == "" { + return jsonErr("missing agent family") + } + bin, err := locateClawtool() + if err != nil { + return jsonErr(err.Error()) + } + cmd := exec.Command(bin, "bridge", "add", family, "--json") + hideConsole(cmd) + if out, err := cmd.CombinedOutput(); err != nil { + msg := firstLine(out) + if msg == "" { + msg = "could not install the " + family + " bridge" + } + return jsonErr(msg) + } + return `{"ok":true}` +} + // LocalCard returns this device's public A2A Agent Card // (/.well-known/agent-card.json) — capability/skill metadata, no secrets — // for the Agents tab to display. Raw daemon body on success. diff --git a/desktop/apps/app-ui/src/App.module.css b/desktop/apps/app-ui/src/App.module.css index 90e223f..33ccf39 100644 --- a/desktop/apps/app-ui/src/App.module.css +++ b/desktop/apps/app-ui/src/App.module.css @@ -1,6 +1,6 @@ .shell { display: flex; flex-direction: column; height: 100vh; } .body { display: flex; flex: 1; min-height: 0; } -.content { flex: 1; min-width: 0; overflow-y: auto; padding: 30px 36px; display: flex; flex-direction: column; } +.content { flex: 1; min-width: 0; overflow-y: auto; padding: 30px 36px; display: flex; flex-direction: column; position: relative; } .status { display: flex; align-items: center; gap: 13px; } .status h1 { font-size: var(--size-2xl); font-weight: 600; letter-spacing: -0.02em; } diff --git a/desktop/apps/app-ui/src/views/Agents.tsx b/desktop/apps/app-ui/src/views/Agents.tsx index 1f37158..70e28d6 100644 --- a/desktop/apps/app-ui/src/views/Agents.tsx +++ b/desktop/apps/app-ui/src/views/Agents.tsx @@ -1,13 +1,40 @@ -import { useEffect, useState } from "react"; -import { AgentRow, Badge, Button, EmptyRow, KVRow, KeyValue, List, SectionHeader } from "@clawtool/design-system"; +import { useEffect, useState, type MouseEvent } from "react"; +import { + AgentIcon, + AgentRow, + Badge, + Button, + EmptyRow, + KVRow, + KeyValue, + List, + SectionHeader, + SidePane, + type BadgeTone, +} from "@clawtool/design-system"; import { App, type Agent, type AgentCard, type Brand } from "@clawtool/bridge"; import { Toast } from "../components/Toast"; import styles from "../App.module.css"; +function statusChip(a: Agent): { label: string; tone: BadgeTone } { + switch (a.status) { + case "callable": + return { label: "ready", tone: "success" }; + case "bridge-missing": + return { label: "bridge missing", tone: "warning" }; + case "binary-missing": + return { label: "not installed", tone: "neutral" }; + case "disabled": + return { label: "disabled", tone: "neutral" }; + default: + return { label: a.status ?? "unknown", tone: "neutral" }; + } +} + function metaFor(a: Agent): string { const parts = [a.family]; if (a.bridge) parts.push(a.bridge); - if (a.tags && a.tags.length) parts.push(a.tags.join(", ")); + if (a.tags?.length) parts.push(a.tags.join(", ")); if (a.sandbox) parts.push(`sandbox: ${a.sandbox}`); return parts.join(" · "); } @@ -15,8 +42,9 @@ function metaFor(a: Agent): string { export function Agents({ brand }: { brand: Brand }) { const [agents, setAgents] = useState([]); const [card, setCard] = useState(null); - const [busy, setBusy] = useState(""); + const [busy, setBusy] = useState(""); const [toast, setToast] = useState(""); + const [selected, setSelected] = useState(null); async function load() { const snap = await App.networkSnapshot(); @@ -28,38 +56,52 @@ export function Agents({ brand }: { brand: Brand }) { useEffect(() => { load(); + const t = setInterval(load, 5000); + return () => clearInterval(t); }, []); - async function connect(a: Agent) { - setBusy(a.instance); - const r = await App.agentClaim(a.instance); - setToast(r.ok ? `Connected ${a.instance}` : typeof r.error === "string" ? r.error : `Couldn't connect ${a.instance}`); - await load(); - setBusy(""); - } - async function disconnect(a: Agent) { + async function run(action: "connect" | "disconnect" | "install", a: Agent) { setBusy(a.instance); - const r = await App.agentRelease(a.instance); - setToast(r.ok ? `Disconnected ${a.instance}` : typeof r.error === "string" ? r.error : `Couldn't disconnect ${a.instance}`); + const r = + action === "disconnect" + ? await App.agentRelease(a.instance) + : action === "install" + ? await App.bridgeAdd(a.family) + : await App.agentClaim(a.instance); + const verb = action === "disconnect" ? "Disconnected" : action === "install" ? "Installed bridge for" : "Connected"; + setToast(r.ok ? `${verb} ${a.instance}` : typeof r.error === "string" ? r.error : `Couldn't ${action} ${a.instance}`); await load(); setBusy(""); } function actionFor(a: Agent) { const b = busy === a.instance; + const stop = (fn: () => void) => (e: MouseEvent) => { + e.stopPropagation(); + fn(); + }; if (a.callable) { return ( - ); } - // bridge-missing / binary-missing → the install/connect affordance - return ( - - ); + if (a.status === "bridge-missing") { + return ( + + ); + } + if (a.status === "binary-missing") { + return ( + + ); + } + return null; } return ( @@ -67,44 +109,94 @@ export function Agents({ brand }: { brand: Brand }) {

          Agents

          AI agents on this device that can call {brand.name}'s tools.
          - + setSelected("card")}> + This device's card → + + } + /> {agents.length ? ( - agents.map((a) => ( - - )) + agents.map((a) => { + const chip = statusChip(a); + return ( + setSelected(a)} + action={ + + {chip.label} + {actionFor(a)} + + } + /> + ); + }) ) : ( - - No agents detected. Install Claude Code, Codex, or Gemini — then they appear here to connect. Or run{" "} - {brand.cli} onboard. - + No agents detected. Install Claude Code, Codex, or Gemini and they appear here to connect. )} - - {card ? ( - <> - - {card.name ? {card.name} : null} - {card.version ? {String(card.version)} : null} - {card.url ? {card.url} : null} - {card.skills?.length ?? 0} - - {card.skills && card.skills.length ? ( -
          - {card.skills.map((s, i) => ( - - {s.name ?? s.id ?? "skill"} - - ))} -
          - ) : null} - - ) : ( - - The A2A card is published once the gateway is running and circle peering is on. - - )} + + + {selected.instance} + + ) : ( + "" + ) + } + onClose={() => setSelected(null)} + > + {selected === "card" ? ( + card ? ( + <> + + {card.name ? {card.name} : null} + {card.version ? {String(card.version)} : null} + {card.url ? {card.url} : null} + {card.skills?.length ?? 0} + + {card.skills?.length ? ( +
          + {card.skills.map((s, i) => ( + + {s.name ?? s.id ?? "skill"} + + ))} +
          + ) : null} + + ) : ( +
          The A2A card publishes once the gateway is running and peering is on.
          + ) + ) : selected ? ( + <> + + {selected.family} + {selected.bridge ? {selected.bridge} : null} + + {statusChip(selected).label} + + {selected.auth_scope ? {selected.auth_scope} : null} + {selected.sandbox ? {selected.sandbox} : null} + {selected.tags?.length ? {selected.tags.join(", ")} : null} + {selected.failover_to?.length ? {selected.failover_to.join(" → ")} : null} + +
          {actionFor(selected)}
          + + ) : null} +
          setToast("")} /> diff --git a/desktop/apps/app-ui/src/views/Network.tsx b/desktop/apps/app-ui/src/views/Network.tsx index 00e3c70..d6dc378 100644 --- a/desktop/apps/app-ui/src/views/Network.tsx +++ b/desktop/apps/app-ui/src/views/Network.tsx @@ -91,18 +91,18 @@ export function Network({ brand }: { brand: Brand }) { <> {banner ?
          {banner}
          : null}

          Cross-device

          -
          Let your devices see each other's agents over your network.
          +
          Pair your machines on the same network so they can see each other's agents.
          - +
          Cross-device peering - {hasKey ? "On" : "Off"} + {hasKey ? "Paired" : "Not paired"}
          - Create a circle to let your devices see each other's agents. Generate a key here, then join the same circle on - your other devices. + Generate a pairing key on one device, then enter it on your other devices on the same network — they'll appear + below once paired.
          {hasKey ? ( @@ -112,14 +112,14 @@ export function Network({ brand }: { brand: Brand }) { Copy
          ) : joinOpen ? (
          setJoinKey(e.target.value)} @@ -141,10 +141,10 @@ export function Network({ brand }: { brand: Brand }) { ) : (
          )} diff --git a/desktop/packages/bridge/src/app.ts b/desktop/packages/bridge/src/app.ts index d2dfafa..a6ccae4 100644 --- a/desktop/packages/bridge/src/app.ts +++ b/desktop/packages/bridge/src/app.ts @@ -48,6 +48,7 @@ export const App = { // agents (connect/disconnect a host + this device's A2A card) agentClaim: (name: string) => callJSON("AgentClaim", OK_FALSE, name), agentRelease: (name: string) => callJSON("AgentRelease", OK_FALSE, name), + bridgeAdd: (family: string) => callJSON("BridgeAdd", OK_FALSE, family), localCard: () => callJSON("LocalCard", {}), // cross-device (circle key + LAN) diff --git a/desktop/packages/design-system/package.json b/desktop/packages/design-system/package.json index 33bbda9..95a9496 100644 --- a/desktop/packages/design-system/package.json +++ b/desktop/packages/design-system/package.json @@ -19,5 +19,8 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "typescript": "^5.6.3" + }, + "dependencies": { + "simple-icons": "^16.21.0" } } diff --git a/desktop/packages/design-system/src/components/AgentIcon.tsx b/desktop/packages/design-system/src/components/AgentIcon.tsx index c422e89..4c0d827 100644 --- a/desktop/packages/design-system/src/components/AgentIcon.tsx +++ b/desktop/packages/design-system/src/components/AgentIcon.tsx @@ -1,28 +1,43 @@ +import { siClaude, siGooglegemini } from "simple-icons"; import styles from "./AgentIcon.module.css"; -// Per-family tint + monogram. Brand-tinted marks (not exact trademark logos). -const FAMILY: Record = { - claude: { tint: "#d97757", mark: "C" }, - codex: { tint: "#10a37f", mark: "O" }, - gemini: { tint: "#4285f4", mark: "G" }, - opencode: { tint: "#8ab4d8", mark: "oc" }, - hermes: { tint: "#a78bfa", mark: "H" }, +type Mark = { path: string; hex: string }; + +// Real brand marks from Simple Icons (the standard licensed brand-icon +// library) where the brand permits redistribution. OpenAI/Codex is NOT in the +// library (the brand restricts its logo — Simple Icons removed it), so those +// fall back to a clean brand-tinted chip. A brand mark is only ever shown to +// indicate that integration (nominative use). +const BRAND: Record = { + claude: { path: siClaude.path, hex: `#${siClaude.hex}` }, + gemini: { path: siGooglegemini.path, hex: `#${siGooglegemini.hex}` }, +}; + +const FALLBACK_TINT: Record = { + codex: "#10a37f", + opencode: "#8ab4d8", + hermes: "#a78bfa", }; export function AgentIcon({ family, size = 26 }: { family: string; size?: number }) { - const f = FAMILY[family.toLowerCase()] ?? { tint: "var(--accent)", mark: (family[0] ?? "?").toUpperCase() }; + const key = family.toLowerCase(); + const brand = BRAND[key]; + if (brand) { + return ( + + + + + + ); + } + const tint = FALLBACK_TINT[key] ?? "var(--accent)"; return ( - {f.mark} + {(family[0] ?? "?").toUpperCase()} ); } diff --git a/desktop/packages/design-system/src/components/AgentRow.module.css b/desktop/packages/design-system/src/components/AgentRow.module.css index ce62538..ee10923 100644 --- a/desktop/packages/design-system/src/components/AgentRow.module.css +++ b/desktop/packages/design-system/src/components/AgentRow.module.css @@ -7,6 +7,7 @@ border-bottom: 1px solid var(--hairline); } .row:hover { background: var(--hover); } +.clickable { cursor: pointer; } .avatar { width: 26px; height: 26px; diff --git a/desktop/packages/design-system/src/components/AgentRow.tsx b/desktop/packages/design-system/src/components/AgentRow.tsx index 2117e0c..8c8e658 100644 --- a/desktop/packages/design-system/src/components/AgentRow.tsx +++ b/desktop/packages/design-system/src/components/AgentRow.tsx @@ -14,6 +14,7 @@ export function AgentRow({ tone = "online", status, action, + onClick, }: { name: string; family?: string; @@ -21,9 +22,10 @@ export function AgentRow({ tone?: DotTone; status?: string; action?: ReactNode; + onClick?: () => void; }) { return ( -
          +
          {family ? : {name.slice(0, 2)}}
          {name}
          diff --git a/desktop/packages/design-system/src/components/SidePane.module.css b/desktop/packages/design-system/src/components/SidePane.module.css new file mode 100644 index 0000000..ad2f28a --- /dev/null +++ b/desktop/packages/design-system/src/components/SidePane.module.css @@ -0,0 +1,36 @@ +.pane { + position: absolute; + top: 0; + right: 0; + height: 100%; + width: 340px; + background: var(--surface-recessed); + border-left: 1px solid var(--hairline); + display: flex; + flex-direction: column; + transform: translateX(100%); + transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1); + z-index: 20; + box-shadow: var(--shadow-overlay); +} +.open { transform: translateX(0); } +.head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 18px; + border-bottom: 1px solid var(--hairline); +} +.title { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 10px; } +.close { + border: 0; + background: none; + color: var(--text-secondary); + cursor: pointer; + display: grid; + place-items: center; + padding: 4px; + border-radius: var(--radius-sm); +} +.close:hover { color: var(--text); background: var(--hover); } +.body { flex: 1; overflow-y: auto; padding: 18px; } diff --git a/desktop/packages/design-system/src/components/SidePane.tsx b/desktop/packages/design-system/src/components/SidePane.tsx new file mode 100644 index 0000000..cc1fe2f --- /dev/null +++ b/desktop/packages/design-system/src/components/SidePane.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; +import styles from "./SidePane.module.css"; + +export function SidePane({ + open, + title, + onClose, + children, +}: { open: boolean; title: ReactNode; onClose: () => void; children: ReactNode }) { + return ( + + ); +} diff --git a/desktop/packages/design-system/src/index.ts b/desktop/packages/design-system/src/index.ts index 489cd27..68e3044 100644 --- a/desktop/packages/design-system/src/index.ts +++ b/desktop/packages/design-system/src/index.ts @@ -12,4 +12,5 @@ export { Switch } from "./components/Switch"; export { KeyValue, KVRow } from "./components/KeyValue"; export { ProgressBar } from "./components/ProgressBar"; export { LogFeed, type LogLine } from "./components/LogFeed"; +export { SidePane } from "./components/SidePane"; export { getPlatform, isMac, type Platform } from "./lib/platform"; diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 0f6a07a..bd7230b 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + simple-icons: + specifier: ^16.21.0 + version: 16.21.0 devDependencies: '@types/react': specifier: ^18.3.12 @@ -717,6 +720,10 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + simple-icons@16.21.0: + resolution: {integrity: sha512-kH6LEx5yvBGVNaVrHnZ7D17G16CmmHiI8DD7MwIwgnWmDFTiFu4PhFZLNdV6bMGr61qbHMMTXLoo/T6C25dMGA==} + engines: {node: '>=0.12.18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1254,6 +1261,8 @@ snapshots: semver@6.3.1: {} + simple-icons@16.21.0: {} + source-map-js@1.2.1: {} tinyglobby@0.2.16: From d3bf56d58369adce11a315cebf5950614ad9a26c Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Wed, 27 May 2026 23:57:26 +0300 Subject: [PATCH 21/86] feat(desktop): broaden real agent logos (Simple Icons) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map every agent family Simple Icons covers to its real brand mark: Claude, Gemini, Ollama, Perplexity, Mistral, GitHub Copilot, Cursor, Windsurf, Hugging Face (+ Nous Hermes via HF). Near-black marks render in the foreground tint so they stay visible on the dark UI. Families the licensed library omits — OpenAI/Codex (brand-restricted) and opencode — keep a clean brand-tinted fallback chip; we don't hand-copy a restricted logo. --- .../src/components/AgentIcon.tsx | 49 +++++++++++++++---- 1 file changed, 39 insertions(+), 10 deletions(-) diff --git a/desktop/packages/design-system/src/components/AgentIcon.tsx b/desktop/packages/design-system/src/components/AgentIcon.tsx index 4c0d827..6660ad2 100644 --- a/desktop/packages/design-system/src/components/AgentIcon.tsx +++ b/desktop/packages/design-system/src/components/AgentIcon.tsx @@ -1,31 +1,60 @@ -import { siClaude, siGooglegemini } from "simple-icons"; +import { + siClaude, + siCursor, + siGithubcopilot, + siGooglegemini, + siHuggingface, + siMistralai, + siOllama, + siPerplexity, + siWindsurf, +} from "simple-icons"; import styles from "./AgentIcon.module.css"; type Mark = { path: string; hex: string }; +const m = (i: { path: string; hex: string }): Mark => ({ path: i.path, hex: `#${i.hex}` }); // Real brand marks from Simple Icons (the standard licensed brand-icon -// library) where the brand permits redistribution. OpenAI/Codex is NOT in the -// library (the brand restricts its logo — Simple Icons removed it), so those -// fall back to a clean brand-tinted chip. A brand mark is only ever shown to -// indicate that integration (nominative use). +// library), keyed by agent family + common aliases. A brand mark is only ever +// shown to indicate that integration (nominative use). Families the library +// omits — notably OpenAI/Codex, whose mark the brand restricts (Simple Icons +// removed it) — fall back to a clean brand-tinted chip rather than a +// hand-copied logo. const BRAND: Record = { - claude: { path: siClaude.path, hex: `#${siClaude.hex}` }, - gemini: { path: siGooglegemini.path, hex: `#${siGooglegemini.hex}` }, + claude: m(siClaude), + anthropic: m(siClaude), + gemini: m(siGooglegemini), + google: m(siGooglegemini), + ollama: m(siOllama), + perplexity: m(siPerplexity), + mistral: m(siMistralai), + copilot: m(siGithubcopilot), + githubcopilot: m(siGithubcopilot), + cursor: m(siCursor), + windsurf: m(siWindsurf), + huggingface: m(siHuggingface), + hermes: m(siHuggingface), // Nous Hermes ships on Hugging Face }; const FALLBACK_TINT: Record = { codex: "#10a37f", + openai: "#10a37f", opencode: "#8ab4d8", - hermes: "#a78bfa", }; export function AgentIcon({ family, size = 26 }: { family: string; size?: number }) { const key = family.toLowerCase(); const brand = BRAND[key]; if (brand) { + // Simple Icons marks are near-black for some brands; on a dark UI use the + // foreground tint for those so they stay visible. + const hx = brand.hex.toLowerCase(); + const dark = hx === "#000000" || hx === "#191919" || hx === "#0b100f"; + const color = dark ? "var(--text)" : brand.hex; + const bg = dark ? "var(--hairline-strong)" : `color-mix(in srgb, ${brand.hex} 16%, transparent)`; return ( - - + + From 6ce80f631d066aab91866d663f38fbb525ac8669 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 00:54:14 +0300 Subject: [PATCH 22/86] feat(desktop): drop-in custom agent logos (assets/logos) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentIcon now resolves icons in order: a custom asset at design-system/src/assets/logos/.svg|png (picked up automatically via import.meta.glob) -> the bundled Simple Icons brand mark -> a tinted fallback chip. Lets the owner supply a logo the default licensed set omits (e.g. Codex) by sourcing an SVG from a licensed set (lobehub.com/icons, MIT) and saving it in that folder — no heavyweight icon dependency, no bundled restricted mark. --- .../design-system/src/assets/logos/README.md | 9 ++++++++ .../src/components/AgentIcon.tsx | 22 +++++++++++++++++++ .../packages/design-system/src/types/css.d.ts | 7 ++++++ 3 files changed, 38 insertions(+) create mode 100644 desktop/packages/design-system/src/assets/logos/README.md diff --git a/desktop/packages/design-system/src/assets/logos/README.md b/desktop/packages/design-system/src/assets/logos/README.md new file mode 100644 index 0000000..06ad00f --- /dev/null +++ b/desktop/packages/design-system/src/assets/logos/README.md @@ -0,0 +1,9 @@ +# Custom agent logos + +Drop a `.svg` (or `.png`) here and `AgentIcon` uses it automatically, +ahead of the built-in Simple Icons marks. Filename = the agent family, +lowercase. Examples: `codex.svg`, `opencode.svg`. + +Source SVGs from a properly-licensed set — e.g. https://lobehub.com/icons +(MIT). This keeps brand-logo sourcing an explicit, owner-made choice and +avoids bundling marks the default icon library omits. diff --git a/desktop/packages/design-system/src/components/AgentIcon.tsx b/desktop/packages/design-system/src/components/AgentIcon.tsx index 6660ad2..6c5ef92 100644 --- a/desktop/packages/design-system/src/components/AgentIcon.tsx +++ b/desktop/packages/design-system/src/components/AgentIcon.tsx @@ -11,6 +11,20 @@ import { } from "simple-icons"; import styles from "./AgentIcon.module.css"; +// Custom logo assets: drop a `.svg` (or .png) into assets/logos/ and +// it's used automatically, ahead of the built-in marks. Lets the project owner +// supply a logo we don't bundle (e.g. brands the licensed icon set omits) +// without us reproducing a restricted mark. +const CUSTOM: Record = (() => { + const files = import.meta.glob("../assets/logos/*.{svg,png}", { eager: true, query: "?url", import: "default" }) as Record; + const out: Record = {}; + for (const path in files) { + const base = path.split("/").pop()?.replace(/\.(svg|png)$/, ""); + if (base) out[base.toLowerCase()] = files[path]; + } + return out; +})(); + type Mark = { path: string; hex: string }; const m = (i: { path: string; hex: string }): Mark => ({ path: i.path, hex: `#${i.hex}` }); @@ -44,6 +58,14 @@ const FALLBACK_TINT: Record = { export function AgentIcon({ family, size = 26 }: { family: string; size?: number }) { const key = family.toLowerCase(); + const custom = CUSTOM[key]; + if (custom) { + return ( + + {family} + + ); + } const brand = BRAND[key]; if (brand) { // Simple Icons marks are near-black for some brands; on a dark UI use the diff --git a/desktop/packages/design-system/src/types/css.d.ts b/desktop/packages/design-system/src/types/css.d.ts index f8c72d2..413fec2 100644 --- a/desktop/packages/design-system/src/types/css.d.ts +++ b/desktop/packages/design-system/src/types/css.d.ts @@ -3,3 +3,10 @@ declare module "*.module.css" { export default classes; } declare module "*.css"; + +interface ImportMeta { + glob: ( + pattern: string, + opts?: { eager?: boolean; query?: string; import?: string }, + ) => Record; +} From 436258cd8e373e526981de473bcff3c835e08779 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 01:20:47 +0300 Subject: [PATCH 23/86] feat(desktop): add owner-supplied Codex logo asset Owner-sourced Codex mark (from lobehub.com/icons, MIT) dropped into assets/logos/; AgentIcon's custom-logo loader renders it automatically. --- desktop/packages/design-system/src/assets/logos/codex.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 desktop/packages/design-system/src/assets/logos/codex.svg diff --git a/desktop/packages/design-system/src/assets/logos/codex.svg b/desktop/packages/design-system/src/assets/logos/codex.svg new file mode 100644 index 0000000..c77ccfd --- /dev/null +++ b/desktop/packages/design-system/src/assets/logos/codex.svg @@ -0,0 +1 @@ +Codex \ No newline at end of file From 536d7aacd25f5b89d0aca57a43ebb6cf019aa5e5 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 01:41:21 +0300 Subject: [PATCH 24/86] feat(pairing): AirDrop-style approve prompt for cross-device pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the daemon's existing pairing ledger (a2a.PairingStore) so the receiving device shows an approve/deny prompt when another machine wants to pair: - core: new `clawtool peer pair list|approve|deny` CLI over GlobalPairingStore (pending requests carry a short code; approve/deny by code or fingerprint). - app: PairList/PairApprove/PairDeny bindings + a PairPrompt that polls the ledger and overlays " wants to pair · code · Deny/Accept". The receiving prompt + approve/deny is complete and unit-tested (CLI). The pending request is created when the other device contacts this one over the existing relay path; an explicit sender-side "Pair" button (proactive request to a discovered device) is the remaining piece and needs a two-device test. --- cmd/clawtool-installer/app.go | 43 +++++++++++ desktop/apps/app-ui/src/App.tsx | 2 + .../src/components/PairPrompt.module.css | 31 ++++++++ .../apps/app-ui/src/components/PairPrompt.tsx | 58 +++++++++++++++ desktop/packages/bridge/src/app.ts | 6 ++ desktop/packages/bridge/src/types.ts | 11 +++ internal/cli/peer.go | 2 + internal/cli/peer_pair.go | 73 +++++++++++++++++++ 8 files changed, 226 insertions(+) create mode 100644 desktop/apps/app-ui/src/components/PairPrompt.module.css create mode 100644 desktop/apps/app-ui/src/components/PairPrompt.tsx create mode 100644 internal/cli/peer_pair.go diff --git a/cmd/clawtool-installer/app.go b/cmd/clawtool-installer/app.go index bad54b8..92ff0ee 100644 --- a/cmd/clawtool-installer/app.go +++ b/cmd/clawtool-installer/app.go @@ -725,6 +725,49 @@ func (a *App) BridgeAdd(family string) string { return `{"ok":true}` } +// PairList returns the daemon's pairing ledger (pending + decided requests) +// as the raw JSON array from `clawtool peer pair list --json`. The app polls +// this to surface an incoming "X wants to pair" approval prompt. +func (a *App) PairList() string { + bin, err := locateClawtool() + if err != nil { + return "[]" + } + cmd := exec.Command(bin, "peer", "pair", "list", "--json") + hideConsole(cmd) + out, err := cmd.Output() + if err != nil || len(out) == 0 { + return "[]" + } + return string(out) +} + +// PairApprove / PairDeny resolve a pending pairing request by its short code +// or fingerprint — the Accept / Deny actions on the approval prompt. +func (a *App) PairApprove(selector string) string { return a.pairDecide("approve", selector) } +func (a *App) PairDeny(selector string) string { return a.pairDecide("deny", selector) } + +func (a *App) pairDecide(action, selector string) string { + selector = strings.TrimSpace(selector) + if selector == "" { + return jsonErr("missing pairing selector") + } + bin, err := locateClawtool() + if err != nil { + return jsonErr(err.Error()) + } + cmd := exec.Command(bin, "peer", "pair", action, selector) + hideConsole(cmd) + if out, err := cmd.CombinedOutput(); err != nil { + msg := firstLine(out) + if msg == "" { + msg = "could not " + action + " the pairing request" + } + return jsonErr(msg) + } + return `{"ok":true}` +} + // LocalCard returns this device's public A2A Agent Card // (/.well-known/agent-card.json) — capability/skill metadata, no secrets — // for the Agents tab to display. Raw daemon body on success. diff --git a/desktop/apps/app-ui/src/App.tsx b/desktop/apps/app-ui/src/App.tsx index 577a3a5..ebe69f1 100644 --- a/desktop/apps/app-ui/src/App.tsx +++ b/desktop/apps/app-ui/src/App.tsx @@ -6,6 +6,7 @@ import { Home } from "./views/Home"; import { Agents } from "./views/Agents"; import { Network } from "./views/Network"; import { Updates } from "./views/Updates"; +import { PairPrompt } from "./components/PairPrompt"; import styles from "./App.module.css"; type View = "home" | "agents" | "network" | "updates"; @@ -52,6 +53,7 @@ export function App() { {view === "updates" ? : null}
          +
          ); } diff --git a/desktop/apps/app-ui/src/components/PairPrompt.module.css b/desktop/apps/app-ui/src/components/PairPrompt.module.css new file mode 100644 index 0000000..1c64b04 --- /dev/null +++ b/desktop/apps/app-ui/src/components/PairPrompt.module.css @@ -0,0 +1,31 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: grid; + place-items: center; + z-index: 100; +} +.modal { + width: 380px; + max-width: calc(100vw - 48px); + background: var(--surface); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-overlay); + padding: 22px; +} +.title { font-size: var(--size-lg); font-weight: 600; } +.body { color: var(--text-secondary); font-size: 13px; line-height: 1.5; margin-top: 8px; } +.body b { color: var(--text); font-weight: 600; } +.code { + margin-top: 14px; + padding: 8px 12px; + background: var(--surface-recessed); + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 12.5px; +} +.code .mono { color: var(--accent); letter-spacing: 0.08em; } +.actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; } diff --git a/desktop/apps/app-ui/src/components/PairPrompt.tsx b/desktop/apps/app-ui/src/components/PairPrompt.tsx new file mode 100644 index 0000000..6d0499b --- /dev/null +++ b/desktop/apps/app-ui/src/components/PairPrompt.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; +import { Button } from "@clawtool/design-system"; +import { App, type PairingRequest } from "@clawtool/bridge"; +import styles from "./PairPrompt.module.css"; + +// Polls the daemon's pairing ledger; when a remote device's request is +// pending, surfaces an approve/deny prompt over the whole app — the +// receiving end of "another device wants to pair with this one". +export function PairPrompt() { + const [req, setReq] = useState(null); + const [busy, setBusy] = useState(false); + + async function poll() { + const list = await App.pairList(); + setReq(Array.isArray(list) ? (list.find((r) => r.state === "pending") ?? null) : null); + } + + useEffect(() => { + poll(); + const t = setInterval(poll, 3000); + return () => clearInterval(t); + }, []); + + if (!req) return null; + + async function decide(approve: boolean) { + if (!req) return; + setBusy(true); + const sel = req.code || req.fingerprint; + await (approve ? App.pairApprove(sel) : App.pairDeny(sel)); + setReq(null); + setBusy(false); + poll(); + } + + return ( +
          +
          +
          Pairing request
          +

          + {req.display_name || req.address || "A device"} on your network wants to pair with this device and see + its agents. +

          +
          + code {req.code} +
          +
          + + +
          +
          +
          + ); +} diff --git a/desktop/packages/bridge/src/app.ts b/desktop/packages/bridge/src/app.ts index a6ccae4..0fb8d4b 100644 --- a/desktop/packages/bridge/src/app.ts +++ b/desktop/packages/bridge/src/app.ts @@ -10,6 +10,7 @@ import type { LanStatus, Mode, NetworkSnapshot, + PairingRequest, PeerAgentsResult, Result, UpdateInfo, @@ -51,6 +52,11 @@ export const App = { bridgeAdd: (family: string) => callJSON("BridgeAdd", OK_FALSE, family), localCard: () => callJSON("LocalCard", {}), + // cross-device pairing approval ledger + pairList: () => callJSON("PairList", []), + pairApprove: (selector: string) => callJSON("PairApprove", OK_FALSE, selector), + pairDeny: (selector: string) => callJSON("PairDeny", OK_FALSE, selector), + // cross-device (circle key + LAN) circleStatus: () => callJSON("CircleStatus", { ok: false }), circleGenerate: () => callJSON("CircleGenerate", OK_FALSE), diff --git a/desktop/packages/bridge/src/types.ts b/desktop/packages/bridge/src/types.ts index 1b2a974..c6372ce 100644 --- a/desktop/packages/bridge/src/types.ts +++ b/desktop/packages/bridge/src/types.ts @@ -62,6 +62,17 @@ export type AgentCard = { [k: string]: unknown; }; +export type PairingState = "pending" | "approved" | "denied"; +export type PairingRequest = { + fingerprint: string; + display_name?: string; + address?: string; + code: string; + state: PairingState; + first_seen?: string; + decided_at?: string; +}; + export type Result = { ok: boolean; error?: string; [k: string]: unknown }; export type CircleStatus = { ok: boolean; has_key?: boolean; key?: string }; export type LanStatus = { ok: boolean; enabled?: boolean }; diff --git a/internal/cli/peer.go b/internal/cli/peer.go index 7bf7eef..ceada8f 100644 --- a/internal/cli/peer.go +++ b/internal/cli/peer.go @@ -126,6 +126,8 @@ func (a *App) runPeer(argv []string) int { return a.runPeerDrain(argv[1:]) case "list": return a.runPeerList(argv[1:]) + case "pair": + return a.runPeerPair(argv[1:]) default: fmt.Fprintf(a.Stderr, "clawtool peer: unknown subcommand %q\n\n%s", argv[0], peerUsage) return 2 diff --git a/internal/cli/peer_pair.go b/internal/cli/peer_pair.go new file mode 100644 index 0000000..fa6106b --- /dev/null +++ b/internal/cli/peer_pair.go @@ -0,0 +1,73 @@ +// Package cli — `clawtool peer pair` subcommand: operator-facing surface over +// the daemon-local pairing ledger (internal/a2a PairingStore). A remote peer's +// first contact is recorded pending (with a short code); this is how the +// receiving operator (and the desktop app's approval prompt) lists and +// approves/denies those requests. +// +// clawtool peer pair list [--json] — show pending/decided requests +// clawtool peer pair approve +// clawtool peer pair deny +package cli + +import ( + "encoding/json" + "flag" + "fmt" + + "github.com/cogitave/clawtool/internal/a2a" +) + +func (a *App) runPeerPair(argv []string) int { + if len(argv) == 0 { + fmt.Fprint(a.Stderr, "usage: clawtool peer pair ...\n") + return 2 + } + store := a2a.GlobalPairingStore() + switch argv[0] { + case "list": + fs := flag.NewFlagSet("peer pair list", flag.ContinueOnError) + asJSON := fs.Bool("json", false, "emit JSON") + if err := fs.Parse(argv[1:]); err != nil { + return 2 + } + reqs := store.List() + if *asJSON { + b, _ := json.Marshal(reqs) + fmt.Fprintln(a.Stdout, string(b)) + return 0 + } + if len(reqs) == 0 { + fmt.Fprintln(a.Stdout, "no pairing requests") + return 0 + } + for _, r := range reqs { + fmt.Fprintf(a.Stdout, "%-8s %-10s %s %s\n", r.State, r.Code, r.DisplayName, r.Fingerprint) + } + return 0 + case "approve", "deny": + if len(argv) < 2 { + fmt.Fprintf(a.Stderr, "usage: clawtool peer pair %s \n", argv[0]) + return 2 + } + sel := argv[1] + var ( + r *a2a.PairingRequest + err error + ) + if argv[0] == "approve" { + r, err = store.Approve(sel) + } else { + r, err = store.Deny(sel) + } + if err != nil { + fmt.Fprintf(a.Stderr, "clawtool peer pair %s: %v\n", argv[0], err) + return 1 + } + b, _ := json.Marshal(map[string]any{"ok": true, "state": r.State, "fingerprint": r.Fingerprint}) + fmt.Fprintln(a.Stdout, string(b)) + return 0 + default: + fmt.Fprintf(a.Stderr, "clawtool peer pair: unknown subcommand %q\n", argv[0]) + return 2 + } +} From 2db774c99bfb3e060d58767147cb89dd774c0f67 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 02:18:49 +0300 Subject: [PATCH 25/86] =?UTF-8?q?feat(pairing):=20proactive=20send=20?= =?UTF-8?q?=E2=80=94=20pick=20a=20discovered=20device=20and=20request=20to?= =?UTF-8?q?=20pair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sender side of cross-device pairing: - core: POST /v1/peers/{id}/pair-request — resolves the peer's address from the registry and relays this device's install fingerprint + display name to the peer's /v1/relay (circle-key authed, mirrors proxyPeerAgents), so the peer records a pending request and shows its approve prompt. New `clawtool peer pair request ` CLI calls it via the local daemon. - app: PairRequest binding + Network "Devices on your network" now lists mDNS-discovered clawtool devices (name + address + status) each with a Pair button that sends the request. Pairs with the existing approve popup: Pair here -> approve prompt there. Cross-device handoff still needs a two-device (Mac+Windows) smoke test. --- cmd/clawtool-installer/app.go | 24 ++++++++++ desktop/apps/app-ui/src/App.module.css | 11 +++++ desktop/apps/app-ui/src/views/Network.tsx | 37 +++++++++++---- desktop/packages/bridge/src/app.ts | 1 + internal/cli/peer_pair.go | 17 ++++++- internal/server/peers_handler.go | 58 +++++++++++++++++++++++ 6 files changed, 138 insertions(+), 10 deletions(-) diff --git a/cmd/clawtool-installer/app.go b/cmd/clawtool-installer/app.go index 92ff0ee..321b582 100644 --- a/cmd/clawtool-installer/app.go +++ b/cmd/clawtool-installer/app.go @@ -768,6 +768,30 @@ func (a *App) pairDecide(action, selector string) string { return `{"ok":true}` } +// PairRequest asks a discovered peer to pair — `clawtool peer pair request +// ` — which makes that device show an approve/deny prompt. Returns +// the daemon's JSON ({ok,sent} | {not_in_circle} | {needs_circle_key} | error). +func (a *App) PairRequest(peerID string) string { + peerID = strings.TrimSpace(peerID) + if peerID == "" { + return jsonErr("missing peer id") + } + bin, err := locateClawtool() + if err != nil { + return jsonErr(err.Error()) + } + cmd := exec.Command(bin, "peer", "pair", "request", peerID) + hideConsole(cmd) + out, err := cmd.Output() + if err != nil { + return jsonErr("could not send the pairing request") + } + if s := strings.TrimSpace(string(out)); s != "" { + return s + } + return `{"ok":true}` +} + // LocalCard returns this device's public A2A Agent Card // (/.well-known/agent-card.json) — capability/skill metadata, no secrets — // for the Agents tab to display. Raw daemon body on success. diff --git a/desktop/apps/app-ui/src/App.module.css b/desktop/apps/app-ui/src/App.module.css index 33ccf39..4eedbf4 100644 --- a/desktop/apps/app-ui/src/App.module.css +++ b/desktop/apps/app-ui/src/App.module.css @@ -57,6 +57,17 @@ .err { color: var(--warning); font-size: 12.5px; margin-top: 8px; } .linklike { background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer; font: inherit; font-size: var(--size-md); } .linklike:hover { text-decoration: underline; } +.deviceRow { + display: flex; + align-items: center; + gap: 12px; + padding: 11px 6px; + border-bottom: 1px solid var(--hairline); +} +.deviceRow:hover { background: var(--hover); } +.deviceName { font-weight: 550; font-size: var(--size-md); } +.deviceMeta { color: var(--text-secondary); font-size: var(--size-sm); margin-top: 1px; font-family: var(--font-mono); } +.deviceRight { margin-left: auto; display: flex; align-items: center; gap: 12px; } .keybox { font: 500 12.5px var(--font-mono); color: var(--text); diff --git a/desktop/apps/app-ui/src/views/Network.tsx b/desktop/apps/app-ui/src/views/Network.tsx index d6dc378..4d25821 100644 --- a/desktop/apps/app-ui/src/views/Network.tsx +++ b/desktop/apps/app-ui/src/views/Network.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Badge, Button, EmptyRow, KVRow, KeyValue, List, SectionHeader, Switch } from "@clawtool/design-system"; +import { Badge, Button, EmptyRow, List, SectionHeader, Switch } from "@clawtool/design-system"; import { App, type Brand, type Peer } from "@clawtool/bridge"; import { Toast } from "../components/Toast"; import styles from "../App.module.css"; @@ -28,6 +28,7 @@ export function Network({ brand }: { brand: Brand }) { const [joinKey, setJoinKey] = useState(""); const [joinErr, setJoinErr] = useState(""); const [toast, setToast] = useState(""); + const [pairing, setPairing] = useState(""); async function loadNetwork() { const snap = await App.networkSnapshot(); @@ -80,6 +81,15 @@ export function Network({ brand }: { brand: Brand }) { setJoinErr(typeof r.error === "string" ? r.error : "Couldn't set the circle key"); } } + async function sendPair(p: Peer) { + setPairing(p.peer_id); + const r = (await App.pairRequest(p.peer_id)) as Record; + if (r.ok && r.sent) setToast(`Pairing request sent to ${p.display_name || p.peer_id}`); + else if (r.needs_circle_key) setToast("Generate a pairing key here first"); + else if (r.not_in_circle) setToast("That device isn't in your circle — share the same pairing key"); + else setToast(typeof r.error === "string" ? r.error : "Couldn't send the request"); + setPairing(""); + } async function toggleLan(next: boolean) { setLanBusy(true); await (next ? App.lanEnable() : App.lanDisable()); @@ -162,20 +172,29 @@ export function Network({ brand }: { brand: Brand }) {
          - + {peers.length ? ( - + {peers.map((p) => ( - - {p.status ?? "—"} - +
          +
          +
          {p.display_name || p.metadata?.hostname || p.peer_id}
          +
          {p.metadata?.address || p.metadata?.hostname || "on this network"}
          +
          + + {p.status ?? "—"} + + +
          ))} -
          + ) : ( - No devices discovered yet. Start {brand.name} on another machine on this network — paired devices appear - automatically over mDNS. + No devices discovered yet. Start {brand.name} on another machine on this network — clawtool devices appear + here automatically over mDNS, then hit Pair to request a connection. )} diff --git a/desktop/packages/bridge/src/app.ts b/desktop/packages/bridge/src/app.ts index 0fb8d4b..823f085 100644 --- a/desktop/packages/bridge/src/app.ts +++ b/desktop/packages/bridge/src/app.ts @@ -56,6 +56,7 @@ export const App = { pairList: () => callJSON("PairList", []), pairApprove: (selector: string) => callJSON("PairApprove", OK_FALSE, selector), pairDeny: (selector: string) => callJSON("PairDeny", OK_FALSE, selector), + pairRequest: (peerID: string) => callJSON("PairRequest", OK_FALSE, peerID), // cross-device (circle key + LAN) circleStatus: () => callJSON("CircleStatus", { ok: false }), diff --git a/internal/cli/peer_pair.go b/internal/cli/peer_pair.go index fa6106b..829d992 100644 --- a/internal/cli/peer_pair.go +++ b/internal/cli/peer_pair.go @@ -13,13 +13,15 @@ import ( "encoding/json" "flag" "fmt" + "net/http" "github.com/cogitave/clawtool/internal/a2a" + "github.com/cogitave/clawtool/internal/daemon" ) func (a *App) runPeerPair(argv []string) int { if len(argv) == 0 { - fmt.Fprint(a.Stderr, "usage: clawtool peer pair ...\n") + fmt.Fprint(a.Stderr, "usage: clawtool peer pair ...\n") return 2 } store := a2a.GlobalPairingStore() @@ -44,6 +46,19 @@ func (a *App) runPeerPair(argv []string) int { fmt.Fprintf(a.Stdout, "%-8s %-10s %s %s\n", r.State, r.Code, r.DisplayName, r.Fingerprint) } return 0 + case "request": + if len(argv) < 2 { + fmt.Fprintf(a.Stderr, "usage: clawtool peer pair request \n") + return 2 + } + var out map[string]any + if err := daemon.HTTPRequest(http.MethodPost, "/v1/peers/"+argv[1]+"/pair-request", nil, &out); err != nil { + fmt.Fprintf(a.Stderr, "clawtool peer pair request: %v\n", err) + return 1 + } + b, _ := json.Marshal(out) + fmt.Fprintln(a.Stdout, string(b)) + return 0 case "approve", "deny": if len(argv) < 2 { fmt.Fprintf(a.Stderr, "usage: clawtool peer pair %s \n", argv[0]) diff --git a/internal/server/peers_handler.go b/internal/server/peers_handler.go index 72b48dc..f637078 100644 --- a/internal/server/peers_handler.go +++ b/internal/server/peers_handler.go @@ -26,6 +26,7 @@ package server import ( + "bytes" "context" "encoding/json" "io" @@ -82,6 +83,10 @@ func handlePeers(w http.ResponseWriter, r *http.Request) { peerID := strings.TrimSuffix(tail, "/agents") proxyPeerAgents(w, r, reg, peerID) + case strings.HasSuffix(tail, "/pair-request") && r.Method == http.MethodPost: + peerID := strings.TrimSuffix(tail, "/pair-request") + sendPairRequest(w, r, reg, peerID) + case tail != "" && !strings.Contains(tail, "/") && r.Method == http.MethodDelete: deregisterPeer(w, r, reg, tail) @@ -420,3 +425,56 @@ func proxyPeerAgents(w http.ResponseWriter, r *http.Request, reg *a2a.Registry, w.WriteHeader(http.StatusOK) _, _ = w.Write(body) } + +// sendPairRequest tells a discovered peer's daemon that this device wants to +// pair: it POSTs a minimal relay to the peer's /v1/relay carrying THIS +// device's install fingerprint + display name. The peer records a pending +// pairing request (and surfaces the approve/deny prompt on its screen). +// Authenticated with the shared circle key, exactly like proxyPeerAgents. +func sendPairRequest(w http.ResponseWriter, r *http.Request, reg *a2a.Registry, peerID string) { + peer := reg.Get(peerID) + if peer == nil { + writeJSON(w, http.StatusNotFound, map[string]any{"error": "no peer with that id", "got_id": peerID}) + return + } + key, _ := a2a.LoadCircleKey() + if key == "" { + writeJSON(w, http.StatusOK, map[string]any{ + "needs_circle_key": true, + "hint": "generate a pairing key here and set the same key on the peer first", + }) + return + } + base := peerBaseURL(peer) + if base == "" { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "peer advertised no reachable address", "peer_id": peerID}) + return + } + reqBody, _ := json.Marshal(map[string]any{ + "from_fingerprint": a2a.InstallID(), + "from_display_name": a2a.InstallDisplayName(), + "text": "wants to pair", + }) + ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/v1/relay", bytes.NewReader(reqBody)) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "bad peer address: " + err.Error()}) + return + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(a2a.CircleKeyHeader, key) + resp, err := http.DefaultClient.Do(req) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "could not reach peer: " + err.Error(), "peer_id": peerID}) + return + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + writeJSON(w, http.StatusOK, map[string]any{"not_in_circle": true, "hint": "this peer doesn't share your pairing key — set the same key on both"}) + return + } + // 202 pairing_required is the SUCCESS case: the peer recorded our request + // and is waiting for its operator to approve on screen. + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "sent": true, "peer_id": peerID, "peer_status": resp.StatusCode}) +} From 302bc19c7c27194c39664ea3cef67d400a9ec46e Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 02:31:47 +0300 Subject: [PATCH 26/86] feat(desktop): Settings view, lucide icons, wordmark in titlebar, leaner Home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings view: About (name/version/cli/install dir), Diagnostics that runs `clawtool doctor` (new RunDoctor binding) and shows the output, and a GitHub link (BrowserOpenURL via a new openURL bridge helper). - Icons: replace the hand-rolled SVGs with lucide-react (nav + frameless window controls); delete the bespoke icons module. - Titlebar: move the clawtool wordmark into the titlebar (leading edge); the sidebar starts straight at the nav. TitleBar gains an optional `brand` slot. - Home: drop the agent list (Agents tab owns that now) — Home is the status hero + a clickable metric strip (Agents / Devices / Cross-device) + footer. --- cmd/clawtool-installer/app.go | 18 ++++++ desktop/apps/_gallery/src/main.tsx | 2 +- desktop/apps/app-ui/package.json | 5 +- desktop/apps/app-ui/src/App.module.css | 14 +++++ desktop/apps/app-ui/src/App.tsx | 20 ++++--- desktop/apps/app-ui/src/icons.tsx | 42 ------------- desktop/apps/app-ui/src/views/Home.tsx | 45 ++------------ desktop/apps/app-ui/src/views/Settings.tsx | 59 +++++++++++++++++++ desktop/packages/bridge/src/app.ts | 3 + desktop/packages/bridge/src/index.ts | 2 +- desktop/packages/bridge/src/runtime.ts | 5 ++ desktop/packages/design-system/package.json | 1 + .../src/components/Sidebar.module.css | 1 + .../design-system/src/components/Sidebar.tsx | 4 +- .../src/components/TitleBar.module.css | 10 +--- .../design-system/src/components/TitleBar.tsx | 47 +++++++-------- desktop/pnpm-lock.yaml | 15 +++++ 17 files changed, 165 insertions(+), 128 deletions(-) delete mode 100644 desktop/apps/app-ui/src/icons.tsx create mode 100644 desktop/apps/app-ui/src/views/Settings.tsx diff --git a/cmd/clawtool-installer/app.go b/cmd/clawtool-installer/app.go index 321b582..a5cd26c 100644 --- a/cmd/clawtool-installer/app.go +++ b/cmd/clawtool-installer/app.go @@ -768,6 +768,24 @@ func (a *App) pairDecide(action, selector string) string { return `{"ok":true}` } +// RunDoctor runs `clawtool doctor` and returns its full output for the +// Settings → Diagnostics panel. doctor exits non-zero when it finds issues, +// but we still want to show what it printed, so the exit code is ignored. +func (a *App) RunDoctor() string { + bin, err := locateClawtool() + if err != nil { + return jsonErr(err.Error()) + } + cmd := exec.Command(bin, "doctor") + hideConsole(cmd) + out, _ := cmd.CombinedOutput() + b, _ := json.Marshal(struct { + OK bool `json:"ok"` + Output string `json:"output"` + }{true, string(out)}) + return string(b) +} + // PairRequest asks a discovered peer to pair — `clawtool peer pair request // ` — which makes that device show an approve/deny prompt. Returns // the daemon's JSON ({ok,sent} | {not_in_circle} | {needs_circle_key} | error). diff --git a/desktop/apps/_gallery/src/main.tsx b/desktop/apps/_gallery/src/main.tsx index b6abbf5..0e2f284 100644 --- a/desktop/apps/_gallery/src/main.tsx +++ b/desktop/apps/_gallery/src/main.tsx @@ -22,7 +22,7 @@ function Gallery() {
          } + brand={} controls={{ onMinimize: noop, onToggleMaximize: noop, onClose: noop }} />
          diff --git a/desktop/apps/app-ui/package.json b/desktop/apps/app-ui/package.json index 043b21f..e420788 100644 --- a/desktop/apps/app-ui/package.json +++ b/desktop/apps/app-ui/package.json @@ -10,9 +10,10 @@ "dependencies": { "@clawtool/bridge": "workspace:*", "@clawtool/design-system": "workspace:*", + "@clawtool/installer-ui": "workspace:*", + "lucide-react": "^1.16.0", "react": "^18.3.1", - "react-dom": "^18.3.1", - "@clawtool/installer-ui": "workspace:*" + "react-dom": "^18.3.1" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/desktop/apps/app-ui/src/App.module.css b/desktop/apps/app-ui/src/App.module.css index 4eedbf4..182d98b 100644 --- a/desktop/apps/app-ui/src/App.module.css +++ b/desktop/apps/app-ui/src/App.module.css @@ -68,6 +68,20 @@ .deviceName { font-weight: 550; font-size: var(--size-md); } .deviceMeta { color: var(--text-secondary); font-size: var(--size-sm); margin-top: 1px; font-family: var(--font-mono); } .deviceRight { margin-left: auto; display: flex; align-items: center; gap: 12px; } +.doctor { + margin-top: 10px; + max-width: 640px; + max-height: 280px; + overflow: auto; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + padding: 12px 14px; + font: 12px/1.6 var(--font-mono); + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; +} .keybox { font: 500 12.5px var(--font-mono); color: var(--text); diff --git a/desktop/apps/app-ui/src/App.tsx b/desktop/apps/app-ui/src/App.tsx index ebe69f1..af9dce5 100644 --- a/desktop/apps/app-ui/src/App.tsx +++ b/desktop/apps/app-ui/src/App.tsx @@ -1,15 +1,16 @@ import { useEffect, useState } from "react"; import { NavItem, Sidebar, StatusDot, TitleBar, Wordmark, type Platform } from "@clawtool/design-system"; import { App as Backend, Win, environmentPlatform, type Brand } from "@clawtool/bridge"; -import { AgentsIcon, HomeIcon, NetworkIcon, UpdatesIcon } from "./icons"; +import { Bot, House, Network as NetIcon, RefreshCw, Settings as SettingsGlyph } from "lucide-react"; import { Home } from "./views/Home"; import { Agents } from "./views/Agents"; import { Network } from "./views/Network"; import { Updates } from "./views/Updates"; +import { Settings } from "./views/Settings"; import { PairPrompt } from "./components/PairPrompt"; import styles from "./App.module.css"; -type View = "home" | "agents" | "network" | "updates"; +type View = "home" | "agents" | "network" | "updates" | "settings"; export function App() { const [platform, setPlatform] = useState("windows"); @@ -28,12 +29,13 @@ export function App() { onClose: () => Win.quit(), }; + const ic = { size: 16, strokeWidth: 1.8 }; + return (
          - + } controls={controls} />
          } footer={ <> @@ -41,16 +43,18 @@ export function App() { } > - } label="Home" active={view === "home"} onClick={() => setView("home")} /> - } label="Agents" active={view === "agents"} onClick={() => setView("agents")} /> - } label="Network" active={view === "network"} onClick={() => setView("network")} /> - } label="Updates" active={view === "updates"} onClick={() => setView("updates")} /> + } label="Home" active={view === "home"} onClick={() => setView("home")} /> + } label="Agents" active={view === "agents"} onClick={() => setView("agents")} /> + } label="Network" active={view === "network"} onClick={() => setView("network")} /> + } label="Updates" active={view === "updates"} onClick={() => setView("updates")} /> + } label="Settings" active={view === "settings"} onClick={() => setView("settings")} />
          {view === "home" ? setView(v as View)} /> : null} {view === "agents" ? : null} {view === "network" ? : null} {view === "updates" ? : null} + {view === "settings" ? : null}
          diff --git a/desktop/apps/app-ui/src/icons.tsx b/desktop/apps/app-ui/src/icons.tsx deleted file mode 100644 index 6a85827..0000000 --- a/desktop/apps/app-ui/src/icons.tsx +++ /dev/null @@ -1,42 +0,0 @@ -const base = { - width: 16, - height: 16, - viewBox: "0 0 24 24", - fill: "none", - stroke: "currentColor", - strokeWidth: 1.8, - strokeLinecap: "round" as const, - strokeLinejoin: "round" as const, -}; - -export const HomeIcon = () => ( - - - - -); - -export const NetworkIcon = () => ( - - - - - - -); - -export const UpdatesIcon = () => ( - - - - -); - -export const AgentsIcon = () => ( - - - - - - -); diff --git a/desktop/apps/app-ui/src/views/Home.tsx b/desktop/apps/app-ui/src/views/Home.tsx index 7a9a823..e6f0a15 100644 --- a/desktop/apps/app-ui/src/views/Home.tsx +++ b/desktop/apps/app-ui/src/views/Home.tsx @@ -1,20 +1,11 @@ import { useEffect, useState } from "react"; -import { - AgentRow, - Button, - EmptyRow, - List, - Metric, - MetricStrip, - SectionHeader, - StatusDot, -} from "@clawtool/design-system"; -import { App, type Agent, type Brand } from "@clawtool/bridge"; +import { Button, Metric, MetricStrip, StatusDot } from "@clawtool/design-system"; +import { App, type Brand } from "@clawtool/bridge"; import styles from "../App.module.css"; export function Home({ brand, onNavigate }: { brand: Brand; onNavigate: (v: string) => void }) { const [online, setOnline] = useState(false); - const [agents, setAgents] = useState([]); + const [agents, setAgents] = useState(0); const [peers, setPeers] = useState(0); const [xd, setXd] = useState(false); @@ -24,10 +15,9 @@ export function Home({ brand, onNavigate }: { brand: Brand; onNavigate: (v: stri setOnline(ok); if (!ok) { App.ensureGateway(); - setAgents([]); return; } - setAgents(snap.agents?.agents ?? []); + setAgents(snap.agents?.count ?? snap.agents?.agents?.length ?? 0); setPeers(snap.peers?.count ?? snap.peers?.peers?.length ?? 0); const c = await App.circleStatus(); setXd(c.ok === true && c.has_key === true); @@ -51,35 +41,12 @@ export function Home({ brand, onNavigate }: { brand: Brand; onNavigate: (v: stri
          - - + onNavigate("agents")} /> + onNavigate("network")} /> onNavigate("network")} />
          - onNavigate("agents")}>Manage →} /> - - {agents.length ? ( - agents.map((a) => ( - - )) - ) : ( - - No agents connected yet.{" "} - - - )} - -
          Closing the window keeps {brand.name} running in the menu bar. + } + /> + {doctor ? ( +
          {doctor}
          + ) : ( +
          + Run a health check on this device's gateway, bridges, and agents ({brand.cli} doctor). +
          + )} + + +
          + +
          + +
          + Closing the window keeps {brand.name} running in the menu bar. +
          + + ); +} diff --git a/desktop/packages/bridge/src/app.ts b/desktop/packages/bridge/src/app.ts index 823f085..d163a05 100644 --- a/desktop/packages/bridge/src/app.ts +++ b/desktop/packages/bridge/src/app.ts @@ -46,6 +46,9 @@ export const App = { checkUpdate: () => callJSON("CheckUpdate", { ok: false }), installUpdate: () => callJSON("InstallUpdate", OK_FALSE), + // diagnostics (clawtool doctor) + runDoctor: () => callJSON<{ ok: boolean; output?: string }>("RunDoctor", { ok: false }), + // agents (connect/disconnect a host + this device's A2A card) agentClaim: (name: string) => callJSON("AgentClaim", OK_FALSE, name), agentRelease: (name: string) => callJSON("AgentRelease", OK_FALSE, name), diff --git a/desktop/packages/bridge/src/index.ts b/desktop/packages/bridge/src/index.ts index 326439d..060f0d3 100644 --- a/desktop/packages/bridge/src/index.ts +++ b/desktop/packages/bridge/src/index.ts @@ -1,3 +1,3 @@ export { App } from "./app"; -export { on, emit, Win, environmentPlatform } from "./runtime"; +export { on, emit, Win, environmentPlatform, openURL } from "./runtime"; export * from "./types"; diff --git a/desktop/packages/bridge/src/runtime.ts b/desktop/packages/bridge/src/runtime.ts index 5160425..2f8c483 100644 --- a/desktop/packages/bridge/src/runtime.ts +++ b/desktop/packages/bridge/src/runtime.ts @@ -47,6 +47,11 @@ export const Win = { quit: () => callRuntime("Quit"), }; +// Open a URL in the user's default browser (Wails BrowserOpenURL). +export function openURL(url: string): void { + callRuntime("BrowserOpenURL", url); +} + export async function environmentPlatform(): Promise { const env = (await callRuntime("Environment")) as { platform?: string } | undefined; const p = env?.platform; diff --git a/desktop/packages/design-system/package.json b/desktop/packages/design-system/package.json index 95a9496..cb9b457 100644 --- a/desktop/packages/design-system/package.json +++ b/desktop/packages/design-system/package.json @@ -21,6 +21,7 @@ "typescript": "^5.6.3" }, "dependencies": { + "lucide-react": "^1.16.0", "simple-icons": "^16.21.0" } } diff --git a/desktop/packages/design-system/src/components/Sidebar.module.css b/desktop/packages/design-system/src/components/Sidebar.module.css index eb51127..3599525 100644 --- a/desktop/packages/design-system/src/components/Sidebar.module.css +++ b/desktop/packages/design-system/src/components/Sidebar.module.css @@ -8,6 +8,7 @@ padding: 18px 12px 12px; } .top { padding: 0 8px 22px; } +.topPad { height: 8px; } .nav { display: flex; flex-direction: column; gap: 1px; } .item { display: flex; diff --git a/desktop/packages/design-system/src/components/Sidebar.tsx b/desktop/packages/design-system/src/components/Sidebar.tsx index cf9c19a..22e7d74 100644 --- a/desktop/packages/design-system/src/components/Sidebar.tsx +++ b/desktop/packages/design-system/src/components/Sidebar.tsx @@ -1,10 +1,10 @@ import type { ReactNode } from "react"; import styles from "./Sidebar.module.css"; -export function Sidebar({ top, children, footer }: { top: ReactNode; children: ReactNode; footer?: ReactNode }) { +export function Sidebar({ top, children, footer }: { top?: ReactNode; children: ReactNode; footer?: ReactNode }) { return ( diff --git a/desktop/packages/design-system/src/components/TitleBar.module.css b/desktop/packages/design-system/src/components/TitleBar.module.css index 11cbd14..0f533b9 100644 --- a/desktop/packages/design-system/src/components/TitleBar.module.css +++ b/desktop/packages/design-system/src/components/TitleBar.module.css @@ -8,14 +8,8 @@ flex: none; } .gutterMac { width: 70px; flex: none; } -.center { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - gap: var(--space-2); - --wails-draggable: drag; -} +.brand { display: flex; align-items: center; gap: var(--space-2); padding-left: 4px; } +.spacer { flex: 1; min-width: 0; } .controls { display: flex; --wails-draggable: no-drag; diff --git a/desktop/packages/design-system/src/components/TitleBar.tsx b/desktop/packages/design-system/src/components/TitleBar.tsx index c9a2d1a..868158d 100644 --- a/desktop/packages/design-system/src/components/TitleBar.tsx +++ b/desktop/packages/design-system/src/components/TitleBar.tsx @@ -1,4 +1,5 @@ import type { CSSProperties, ReactNode } from "react"; +import { Minus, Square, X } from "lucide-react"; import type { Platform } from "../lib/platform"; import styles from "./TitleBar.module.css"; @@ -8,42 +9,38 @@ export type WindowControls = { onClose: () => void; }; -// Frameless custom titlebar. Controls sit on the LEFT for macOS (where the -// traffic lights live) and on the RIGHT elsewhere — the one platform -// difference users expect. The whole bar is the drag region; controls and -// `center` opt out of dragging. +// Frameless custom titlebar. Window controls sit on the right (Windows/Linux); +// on macOS the native inset traffic lights live top-left, so we reserve a +// gutter and render no custom buttons. `brand` (e.g. the wordmark) sits at the +// leading edge of the bar. The whole bar is the drag region; buttons opt out. export function TitleBar({ platform, - center, + brand, controls, -}: { platform: Platform; center?: ReactNode; controls: WindowControls }) { +}: { platform: Platform; brand?: ReactNode; controls: WindowControls }) { const onMac = platform === "darwin"; - const buttons = ( -
          - - - -
          - ); - return (
          - {/* macOS keeps its native inset traffic lights (top-left): reserve a - gutter and render no custom buttons. Windows/Linux: custom controls - on the right. */} {onMac ?
          : null} -
          {center}
          - {onMac ? null : buttons} + {brand ?
          {brand}
          : null} +
          + {onMac ? null : ( +
          + + + +
          + )}
          ); } diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index bd7230b..57f7f6d 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: '@clawtool/installer-ui': specifier: workspace:* version: link:../installer-ui + lucide-react: + specifier: ^1.16.0 + version: 1.16.0(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -144,6 +147,9 @@ importers: packages/design-system: dependencies: + lucide-react: + specifier: ^1.16.0 + version: 1.16.0(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -672,6 +678,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@1.16.0: + resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1196,6 +1207,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@1.16.0(react@18.3.1): + dependencies: + react: 18.3.1 + ms@2.1.3: {} nanoid@3.3.12: {} From ded2da8caf8cfc4a55b90eea9ce6a267b4985279 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 03:14:22 +0300 Subject: [PATCH 27/86] feat(cli): expose release notes and url in upgrade --check --json The desktop Updates view shows a dynamic "What's new" section, so the machine-readable check needs the release body + page URL alongside the existing version fields. Both are omitempty, keeping the contract backward compatible. --- internal/cli/upgrade.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/cli/upgrade.go b/internal/cli/upgrade.go index 74b4809..028eba2 100644 --- a/internal/cli/upgrade.go +++ b/internal/cli/upgrade.go @@ -36,6 +36,8 @@ type upgradeCheck struct { Latest string `json:"latest"` UpdateAvailable bool `json:"update_available"` Found bool `json:"found"` // false = no release published yet + ReleaseNotes string `json:"release_notes,omitempty"` + URL string `json:"url,omitempty"` } func (a *App) runUpgrade(argv []string) int { @@ -116,7 +118,7 @@ func (a *App) runUpgrade(argv []string) int { latestVersion := latest.Version() if isComparableVersion(currentVersion) && latest.LessOrEqual(currentVersion) { if jsonOut { - a.emitUpgradeCheck(upgradeCheck{Current: currentVersion, Latest: latestVersion, UpdateAvailable: false, Found: true}) + a.emitUpgradeCheck(upgradeCheck{Current: currentVersion, Latest: latestVersion, UpdateAvailable: false, Found: true, ReleaseNotes: latest.ReleaseNotes, URL: latest.URL}) return 0 } renderUpToDate(ux, currentVersion, latestVersion) @@ -124,7 +126,7 @@ func (a *App) runUpgrade(argv []string) int { } if jsonOut { - a.emitUpgradeCheck(upgradeCheck{Current: currentVersion, Latest: latestVersion, UpdateAvailable: true, Found: true}) + a.emitUpgradeCheck(upgradeCheck{Current: currentVersion, Latest: latestVersion, UpdateAvailable: true, Found: true, ReleaseNotes: latest.ReleaseNotes, URL: latest.URL}) return 0 } From a185506aa21a5e80a16781d4d34b6f8ccdb84dff Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 03:14:33 +0300 Subject: [PATCH 28/86] =?UTF-8?q?feat(desktop):=20UI=20polish=20round=20?= =?UTF-8?q?=E2=80=94=20shared=20header,=20expandable=20devices,=20dynamic?= =?UTF-8?q?=20updates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shared view header (title left, action button right) across Home/Agents/ Network/Updates so every screen aligns the same way; content uses the full width instead of a capped column. - Home: "Engine is running" with the metric strip on the right, pulse removed. - Network: device rows expand via a chevron to list that device's agents (logo, family/bridge/tags, status) loaded from peerAgents; "Settings" side pane holds discoverability + pairing key; the pairing key stays locked (blurred, lock icon) until the device is discoverable; device list no longer clips. - Updates: a single state-driven button (Check now → Install vX → installing shimmer → Restart to finish) plus a dynamic "What's new" changelog rendered from the release notes the CLI now returns. - Settings: GitHub link as a corner icon. - Add a Vite dev-server stub layer so the UI runs in a plain browser with realistic data for iteration. --- desktop/apps/app-ui/src/App.module.css | 140 ++++++++- desktop/apps/app-ui/src/App.tsx | 12 +- desktop/apps/app-ui/src/dev-stubs.ts | 97 ++++++ desktop/apps/app-ui/src/main.tsx | 9 + desktop/apps/app-ui/src/views/Agents.tsx | 22 +- desktop/apps/app-ui/src/views/Home.tsx | 21 +- desktop/apps/app-ui/src/views/Network.tsx | 294 ++++++++++++------ desktop/apps/app-ui/src/views/Settings.tsx | 18 +- desktop/apps/app-ui/src/views/Updates.tsx | 147 +++++++-- desktop/packages/bridge/src/types.ts | 2 + .../src/components/GithubIcon.tsx | 12 + .../src/components/KeyValue.module.css | 2 +- desktop/packages/design-system/src/index.ts | 1 + 13 files changed, 623 insertions(+), 154 deletions(-) create mode 100644 desktop/apps/app-ui/src/dev-stubs.ts create mode 100644 desktop/packages/design-system/src/components/GithubIcon.tsx diff --git a/desktop/apps/app-ui/src/App.module.css b/desktop/apps/app-ui/src/App.module.css index 182d98b..59c5722 100644 --- a/desktop/apps/app-ui/src/App.module.css +++ b/desktop/apps/app-ui/src/App.module.css @@ -1,14 +1,13 @@ .shell { display: flex; flex-direction: column; height: 100vh; } .body { display: flex; flex: 1; min-height: 0; } .content { flex: 1; min-width: 0; overflow-y: auto; padding: 30px 36px; display: flex; flex-direction: column; position: relative; } +/* One consistent content column so every view uses the same full width. */ +.page { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; } .status { display: flex; align-items: center; gap: 13px; } .status h1 { font-size: var(--size-2xl); font-weight: 600; letter-spacing: -0.02em; } .status .sub { color: var(--text-secondary); font-size: 13px; margin-top: 2px; } -.status .right { margin-left: auto; text-align: right; } -.status .host { font: 500 12px var(--font-mono); color: var(--text-secondary); } - -.metrics { margin: 28px 0 4px; } +.status .metricsRight { margin-left: auto; } .foot { margin-top: auto; padding-top: 18px; @@ -20,6 +19,21 @@ } .vh { font-size: var(--size-xl); font-weight: 600; letter-spacing: -0.02em; } .lead { color: var(--text-secondary); font-size: 13px; margin-top: 2px; } +/* Shared view header: title block on the left, action button on the far right. */ +.head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.head > .actionSlot { flex: none; margin-top: 2px; } +.locked { position: relative; } +.lockedInner { filter: blur(2.5px); opacity: 0.45; pointer-events: none; user-select: none; } +.lockNote { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + color: var(--text-secondary); + font-size: 12.5px; +} .banner { margin-bottom: 16px; padding: 11px 14px; @@ -28,7 +42,7 @@ color: var(--text-secondary); font-size: 13px; } -.xdesc { color: var(--text-secondary); font-size: 12.5px; margin-top: 8px; line-height: 1.5; max-width: 520px; } +.xdesc { color: var(--text-secondary); font-size: 12.5px; margin-top: 8px; line-height: 1.5; } .xrow { display: flex; align-items: center; gap: 12px; margin-top: 14px; } .switchRow { display: flex; @@ -37,7 +51,6 @@ margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--hairline); - max-width: 560px; } .switchRow .txt { flex: 1; } .switchRow .st { font-weight: 600; font-size: 13.5px; } @@ -57,20 +70,86 @@ .err { color: var(--warning); font-size: 12.5px; margin-top: 8px; } .linklike { background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer; font: inherit; font-size: var(--size-md); } .linklike:hover { text-decoration: underline; } +.device { border-bottom: 1px solid var(--hairline); } .deviceRow { display: flex; align-items: center; - gap: 12px; + gap: 10px; padding: 11px 6px; - border-bottom: 1px solid var(--hairline); } .deviceRow:hover { background: var(--hover); } +.chevron { + flex: none; + display: grid; + place-items: center; + width: 24px; + height: 24px; + border: 0; + background: none; + border-radius: var(--radius-sm); + color: var(--text-tertiary); + cursor: pointer; + transition: color 0.14s, background 0.14s; +} +.chevron:hover { color: var(--text); background: var(--hover); } +.agentSub { + padding: 4px 6px 12px 40px; + display: flex; + flex-direction: column; + gap: 2px; +} +.subMuted { color: var(--text-secondary); font-size: 12.5px; padding: 8px 0; } +.subAgent { + display: flex; + align-items: center; + gap: 11px; + padding: 8px 10px; + border-radius: var(--radius-sm); +} +.subAgent:hover { background: var(--hover); } +.subAgentName { font-weight: 550; font-size: 13px; } +.subAgentMeta { + color: var(--text-secondary); + font-size: 12px; + margin-top: 1px; + font-family: var(--font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} .deviceName { font-weight: 550; font-size: var(--size-md); } .deviceMeta { color: var(--text-secondary); font-size: var(--size-sm); margin-top: 1px; font-family: var(--font-mono); } -.deviceRight { margin-left: auto; display: flex; align-items: center; gap: 12px; } +.deviceRight { margin-left: auto; display: flex; align-items: center; gap: 12px; flex: none; } +.field { + margin-top: 18px; + padding-top: 16px; + border-top: 1px solid var(--hairline); +} +.fieldHead { display: flex; align-items: center; justify-content: space-between; } +.deviceList { + margin: 0 -6px; + padding: 0 6px; +} +.deviceName { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.emptyFilter { color: var(--text-secondary); font-size: 13px; padding: 14px 6px; } +.cornerIcon { + position: absolute; + right: 28px; + bottom: 24px; + width: 34px; + height: 34px; + display: grid; + place-items: center; + border: 1px solid var(--hairline); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text-secondary); + cursor: pointer; + transition: color 0.14s, border-color 0.14s; +} +.cornerIcon:hover { color: var(--text); border-color: var(--hairline-strong); } .doctor { margin-top: 10px; - max-width: 640px; max-height: 280px; overflow: auto; background: var(--surface); @@ -82,6 +161,47 @@ white-space: pre-wrap; word-break: break-word; } +.changelog { margin-top: 26px; } +.clHead { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +.clTitle { font-weight: 600; font-size: var(--size-md); } +.clBody { + margin-top: 10px; + border-top: 1px solid var(--hairline); + padding-top: 12px; +} +.clH { + font-weight: 600; + font-size: 11.5px; + color: var(--text); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 14px 0 5px; +} +.clH:first-child { margin-top: 0; } +.clBullet { + display: flex; + gap: 9px; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.55; + padding: 2px 0; +} +.clBullet::before { content: "•"; color: var(--accent); flex: none; } +.clPara { color: var(--text-secondary); font-size: 13px; line-height: 1.6; padding: 3px 0; } +.clEmpty { color: var(--text-secondary); font-size: 13px; margin-top: 12px; } +.updateBtn { position: relative; overflow: hidden; min-width: 200px; text-align: center; } +.updateBtn .btnLabel { position: relative; z-index: 1; } +.updateBtn .fill { + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent-ink) 22%, transparent), transparent); + background-size: 220% 100%; + animation: updShimmer 1.1s linear infinite; +} +@keyframes updShimmer { + from { background-position: 220% 0; } + to { background-position: -220% 0; } +} .keybox { font: 500 12.5px var(--font-mono); color: var(--text); diff --git a/desktop/apps/app-ui/src/App.tsx b/desktop/apps/app-ui/src/App.tsx index af9dce5..bc54ff1 100644 --- a/desktop/apps/app-ui/src/App.tsx +++ b/desktop/apps/app-ui/src/App.tsx @@ -50,11 +50,13 @@ export function App() { } label="Settings" active={view === "settings"} onClick={() => setView("settings")} />
          - {view === "home" ? setView(v as View)} /> : null} - {view === "agents" ? : null} - {view === "network" ? : null} - {view === "updates" ? : null} - {view === "settings" ? : null} +
          + {view === "home" ? setView(v as View)} /> : null} + {view === "agents" ? : null} + {view === "network" ? : null} + {view === "updates" ? : null} + {view === "settings" ? : null} +
          diff --git a/desktop/apps/app-ui/src/dev-stubs.ts b/desktop/apps/app-ui/src/dev-stubs.ts new file mode 100644 index 0000000..f75cc01 --- /dev/null +++ b/desktop/apps/app-ui/src/dev-stubs.ts @@ -0,0 +1,97 @@ +// Dev-only mocks for the Wails-injected globals, so the app runs in a plain +// browser (`pnpm dev`) with realistic data for UI iteration. Imported for its +// side effect from main.tsx ONLY when running under Vite dev AND not inside +// Wails (window.go absent). Never bundled into the Wails build path that +// matters — under Wails, window.go exists, so this no-ops. +type AnyFn = (...args: unknown[]) => unknown; + +const J = (v: unknown) => Promise.resolve(JSON.stringify(v)); + +const agents = [ + { instance: "claude", family: "claude", status: "callable", callable: true, tags: ["code"] }, + { instance: "codex", family: "codex", bridge: "codex-bridge", status: "callable", callable: true }, + { instance: "gemini", family: "gemini", bridge: "gemini-bridge", status: "bridge-missing", callable: false }, + { instance: "opencode", family: "opencode", status: "binary-missing", callable: false }, +]; +const peers = [ + { peer_id: "p1", display_name: "win-pc", status: "online", metadata: { hostname: "win-pc", address: "192.168.1.42:52828" } }, + { peer_id: "p2", display_name: "macbook-air", status: "offline", metadata: { hostname: "macbook-air", address: "192.168.1.51:52828" } }, +]; + +const peerAgents: Record>> = { + p1: [ + { instance: "claude", family: "claude", status: "callable", callable: true, tags: ["code"] }, + { instance: "codex", family: "codex", bridge: "codex-bridge", status: "callable", callable: true }, + { instance: "gemini", family: "gemini", bridge: "gemini-bridge", status: "callable", callable: true, tags: ["vision"] }, + { instance: "opencode", family: "opencode", status: "callable", callable: true }, + { instance: "cursor", family: "cursor", status: "callable", callable: true }, + { instance: "copilot", family: "copilot", status: "bridge-missing", callable: false }, + ], + p2: [{ instance: "claude", family: "claude", status: "callable", callable: true, tags: ["code"] }], +}; + +export function installDevStubs(platform: "windows" | "darwin" = "windows") { + const g = globalThis as Record; + if (g.go) return; // real Wails present + const App: Record = { + Mode: () => Promise.resolve("app"), + Brand: () => + J({ name: "clawtool", cli: "clawtool", tagline: "An agent gateway for your machine — one tool layer, everywhere.", installDir: "%LOCALAPPDATA%\\Programs\\Clawtool", version: "0.22.171" }), + IsInitialized: () => Promise.resolve(true), + EnterApp: () => Promise.resolve(), + EnsureGateway: () => J({ ok: true }), + NetworkSnapshot: () => J({ ok: true, agents: { count: agents.length, agents }, peers: { count: peers.length, peers } }), + PeerAgents: (...a: unknown[]) => J({ agents: peerAgents[String(a[0])] ?? [] }), + CircleStatus: () => J({ ok: true, has_key: true, key: "64f5612e49ce302b3d9f0ffcec383fa5760801d854434c39fec789c121bf5a77" }), + CircleGenerate: () => J({ ok: true, key: "ab".repeat(32) }), + CircleSet: () => J({ ok: true }), + CircleClear: () => J({ ok: true }), + LanStatus: () => J({ ok: true, enabled: true }), + LanEnable: () => J({ ok: true }), + LanDisable: () => J({ ok: true }), + CheckUpdate: () => + J({ + ok: true, + found: true, + current: "0.22.171", + latest: "0.22.180", + update_available: true, + url: "https://github.com/cogitave/clawtool/releases/tag/v0.22.180", + release_notes: [ + "## Highlights", + "- Cross-device pairing now works over the local network with a shared pairing key", + "- Agents on a paired device show inline under each device in Network", + "- Faster gateway startup and healthier daemon recovery after a crash", + "## Milestones", + "- M3 desktop shell shipped: frameless titlebar, unified app surface", + "- A2A card publishing wired end-to-end", + "## Fixes", + "- Pairing key stays locked until the device is discoverable", + "- Update flow restarts the daemon automatically after a binary swap", + ].join("\n"), + }), + InstallUpdate: () => J({ ok: true }), + AgentClaim: () => J({ ok: true }), + AgentRelease: () => J({ ok: true }), + BridgeAdd: () => J({ ok: true }), + LocalCard: () => + J({ name: "clawtool@mac-studio", version: "1.0", url: "http://mac-studio.local:52828", skills: [{ id: "bash", name: "Bash" }, { id: "read", name: "Read" }, { id: "edit", name: "Edit" }, { id: "dispatch", name: "Agent dispatch" }] }), + PairList: () => J([]), + PairApprove: () => J({ ok: true }), + PairDeny: () => J({ ok: true }), + PairRequest: () => J({ ok: true, sent: true }), + RunDoctor: () => J({ ok: true, output: "clawtool doctor — 0.22.171\n\n[binary] ✓ clawtool on PATH\n[daemon] ✓ running on 127.0.0.1:64205\n[agents] ✓ claude, codex callable\n[bridges] ⚠ gemini bridge missing\n\n1 warning, 0 errors" }), + Quit: () => {}, + }; + g.go = { main: { App } }; + g.runtime = { + Environment: () => Promise.resolve({ platform }), + EventsOn: () => () => {}, + EventsEmit: () => {}, + WindowMinimise: () => {}, + WindowToggleMaximise: () => {}, + WindowIsMaximised: () => Promise.resolve(false), + Quit: () => {}, + BrowserOpenURL: (url: unknown) => window.open(String(url), "_blank"), + }; +} diff --git a/desktop/apps/app-ui/src/main.tsx b/desktop/apps/app-ui/src/main.tsx index 9ef557d..10e3fcd 100644 --- a/desktop/apps/app-ui/src/main.tsx +++ b/desktop/apps/app-ui/src/main.tsx @@ -3,6 +3,15 @@ import { createRoot } from "react-dom/client"; import "@clawtool/design-system/global.css"; import { Root } from "./Root"; +// Dev-only: when running in a plain browser (vite dev, no Wails), install mock +// backend globals so the UI renders with realistic data. ?platform=darwin +// switches the titlebar layout. Under Wails this is a no-op (window.go exists). +if (import.meta.env.DEV) { + const { installDevStubs } = await import("./dev-stubs"); + const p = new URLSearchParams(location.search).get("platform"); + installDevStubs(p === "darwin" ? "darwin" : "windows"); +} + createRoot(document.getElementById("root")!).render( diff --git a/desktop/apps/app-ui/src/views/Agents.tsx b/desktop/apps/app-ui/src/views/Agents.tsx index 70e28d6..8f902c4 100644 --- a/desktop/apps/app-ui/src/views/Agents.tsx +++ b/desktop/apps/app-ui/src/views/Agents.tsx @@ -106,17 +106,19 @@ export function Agents({ brand }: { brand: Brand }) { return ( <> -

          Agents

          -
          AI agents on this device that can call {brand.name}'s tools.
          - - setSelected("card")}> - This device's card → +
          +
          +

          Agents

          +
          AI agents on this device that can call {brand.name}'s tools.
          +
          +
          + - } - /> +
          +
          + + {agents.length ? ( agents.map((a) => { diff --git a/desktop/apps/app-ui/src/views/Home.tsx b/desktop/apps/app-ui/src/views/Home.tsx index e6f0a15..bd5cc3c 100644 --- a/desktop/apps/app-ui/src/views/Home.tsx +++ b/desktop/apps/app-ui/src/views/Home.tsx @@ -30,21 +30,20 @@ export function Home({ brand, onNavigate }: { brand: Brand; onNavigate: (v: stri return ( <>
          - +
          -

          {online ? `${brand.name} is running` : "Starting the gateway…"}

          -
          +

          {online ? "Engine is running" : "Starting the engine…"}

          +
          {online ? `This device is reachable as a ${brand.name} gateway.` : "Bringing the local gateway online…"}
          -
          - -
          - - onNavigate("agents")} /> - onNavigate("network")} /> - onNavigate("network")} /> - +
          + + onNavigate("agents")} /> + onNavigate("network")} /> + onNavigate("network")} /> + +
          diff --git a/desktop/apps/app-ui/src/views/Network.tsx b/desktop/apps/app-ui/src/views/Network.tsx index 4d25821..6d3ce39 100644 --- a/desktop/apps/app-ui/src/views/Network.tsx +++ b/desktop/apps/app-ui/src/views/Network.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from "react"; -import { Badge, Button, EmptyRow, List, SectionHeader, Switch } from "@clawtool/design-system"; -import { App, type Brand, type Peer } from "@clawtool/bridge"; +import { AgentIcon, Badge, Button, EmptyRow, List, SectionHeader, SidePane, Switch } from "@clawtool/design-system"; +import type { BadgeTone } from "@clawtool/design-system"; +import { ChevronDown, ChevronRight, Lock } from "lucide-react"; +import { App, type Agent, type Brand, type Peer } from "@clawtool/bridge"; import { Toast } from "../components/Toast"; import styles from "../App.module.css"; @@ -8,6 +10,28 @@ function maskKey(k: string): string { return k && k.length > 14 ? `${k.slice(0, 8)}…${k.slice(-4)}` : k; } +function statusChip(a: Agent): { label: string; tone: BadgeTone } { + switch (a.status) { + case "callable": + return { label: "ready", tone: "success" }; + case "bridge-missing": + return { label: "bridge missing", tone: "warning" }; + case "binary-missing": + return { label: "not installed", tone: "neutral" }; + default: + return { label: a.status ?? "unknown", tone: "neutral" }; + } +} + +function metaFor(a: Agent): string { + const parts = [a.family]; + if (a.bridge) parts.push(a.bridge); + if (a.tags?.length) parts.push(a.tags.join(", ")); + return parts.join(" · "); +} + +type AgentState = "loading" | "none" | Agent[]; + async function copyText(text: string): Promise { try { await navigator.clipboard.writeText(text); @@ -29,6 +53,10 @@ export function Network({ brand }: { brand: Brand }) { const [joinErr, setJoinErr] = useState(""); const [toast, setToast] = useState(""); const [pairing, setPairing] = useState(""); + const [filter, setFilter] = useState(""); + const [deviceOpen, setDeviceOpen] = useState(false); + const [expanded, setExpanded] = useState>(new Set()); + const [agentsByPeer, setAgentsByPeer] = useState>({}); async function loadNetwork() { const snap = await App.networkSnapshot(); @@ -90,6 +118,22 @@ export function Network({ brand }: { brand: Brand }) { else setToast(typeof r.error === "string" ? r.error : "Couldn't send the request"); setPairing(""); } + async function toggleExpand(p: Peer) { + const next = new Set(expanded); + if (next.has(p.peer_id)) { + next.delete(p.peer_id); + setExpanded(next); + return; + } + next.add(p.peer_id); + setExpanded(next); + if (!agentsByPeer[p.peer_id]) { + setAgentsByPeer((m) => ({ ...m, [p.peer_id]: "loading" })); + const r = (await App.peerAgents(p.peer_id)) as Record; + const list = Array.isArray(r.agents) ? (r.agents as Agent[]) : []; + setAgentsByPeer((m) => ({ ...m, [p.peer_id]: list.length ? list : "none" })); + } + } async function toggleLan(next: boolean) { setLanBusy(true); await (next ? App.lanEnable() : App.lanDisable()); @@ -97,108 +141,178 @@ export function Network({ brand }: { brand: Brand }) { setLanBusy(false); } + const q = filter.trim().toLowerCase(); + const shown = q + ? peers.filter((p) => + `${p.display_name ?? ""} ${p.metadata?.hostname ?? ""} ${p.metadata?.address ?? ""} ${p.peer_id}` + .toLowerCase() + .includes(q), + ) + : peers; + return ( <> {banner ?
          {banner}
          : null} -

          Cross-device

          -
          Pair your machines on the same network so they can see each other's agents.
          - - -
          - Cross-device peering - - {hasKey ? "Paired" : "Not paired"} - -
          -
          - Generate a pairing key on one device, then enter it on your other devices on the same network — they'll appear - below once paired. -
          - - {hasKey ? ( -
          - {maskKey(key)} - - +
          +
          +

          Network

          +
          Pair the machines on your network so they can use each other's agents.
          - ) : joinOpen ? ( -
          - setJoinKey(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") join(); - if (e.key === "Escape") setJoinOpen(false); - }} - /> - {joinErr ?
          {joinErr}
          : null} -
          - - -
          -
          - ) : ( -
          - - -
          - )} - -
          -
          -
          - Reachable on your LAN -
          -
          - Let circle devices on your network read this device's agent list. Code execution stays local-only; the - first time, your OS may ask to allow it through the firewall. -
          -
          - - {peers.length ? ( - - {peers.map((p) => ( -
          -
          -
          {p.display_name || p.metadata?.hostname || p.peer_id}
          -
          {p.metadata?.address || p.metadata?.hostname || "on this network"}
          -
          - - {p.status ?? "—"} - - -
          - ))} -
          - ) : ( + + {peers.length === 0 ? ( - No devices discovered yet. Start {brand.name} on another machine on this network — clawtool devices appear - here automatically over mDNS, then hit Pair to request a connection. + No devices discovered yet. Start {brand.name} on another machine on this network — devices appear here + automatically, then Pair to request a connection. + ) : ( + <> + {peers.length > 6 ? ( + setFilter(e.target.value)} + /> + ) : null} +
          + {shown.map((p) => { + const open = expanded.has(p.peer_id); + const st = agentsByPeer[p.peer_id]; + return ( +
          +
          + +
          +
          {p.display_name || p.metadata?.hostname || p.peer_id}
          +
          + {p.metadata?.address || p.metadata?.hostname || "on this network"} +
          +
          + + {p.status ?? "—"} + + +
          + {open ? ( +
          + {st === "loading" ? ( +
          Loading agents…
          + ) : st === "none" || st === undefined ? ( +
          No agents shared — pair with this device to see its agents.
          + ) : ( + st.map((a) => { + const chip = statusChip(a); + return ( +
          + +
          +
          {a.instance}
          +
          {metaFor(a)}
          +
          + {chip.label} +
          + ); + }) + )} +
          + ) : null} +
          + ); + })} + {shown.length === 0 ?
          No devices match “{filter}”.
          : null} +
          + )} + setDeviceOpen(false)}> +
          +
          +
          Discoverable on your network
          +
          + Paired devices on your network can see this machine and its agents. Code execution stays local-only; the + first time, your OS may ask to allow it through the firewall. +
          +
          + +
          + +
          + {lan ? null : ( +
          + + Turn on discoverability to set a pairing key +
          + )} +
          +
          +
          Pairing key
          + {hasKey ? "Active" : "Not set"} +
          +
          + Devices that share this key form one trusted network. Generate it on one device, then enter it on the + others. +
          + {hasKey ? ( +
          + {maskKey(key)} + + +
          + ) : joinOpen ? ( +
          + setJoinKey(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") join(); + if (e.key === "Escape") setJoinOpen(false); + }} + /> + {joinErr ?
          {joinErr}
          : null} +
          + + +
          +
          + ) : ( +
          + + +
          + )} +
          +
          +
          + setToast("")} /> ); diff --git a/desktop/apps/app-ui/src/views/Settings.tsx b/desktop/apps/app-ui/src/views/Settings.tsx index e20d2a9..9749f32 100644 --- a/desktop/apps/app-ui/src/views/Settings.tsx +++ b/desktop/apps/app-ui/src/views/Settings.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Button, KVRow, KeyValue, SectionHeader } from "@clawtool/design-system"; +import { Button, GithubIcon, KVRow, KeyValue, SectionHeader } from "@clawtool/design-system"; import { App, openURL, type Brand } from "@clawtool/bridge"; import styles from "../App.module.css"; @@ -44,16 +44,18 @@ export function Settings({ brand }: { brand: Brand }) {
          )} - -
          - -
          -
          Closing the window keeps {brand.name} running in the menu bar.
          + + ); } diff --git a/desktop/apps/app-ui/src/views/Updates.tsx b/desktop/apps/app-ui/src/views/Updates.tsx index 8f50551..620be1a 100644 --- a/desktop/apps/app-ui/src/views/Updates.tsx +++ b/desktop/apps/app-ui/src/views/Updates.tsx @@ -1,19 +1,54 @@ -import { useEffect, useState } from "react"; +import { type ReactNode, useEffect, useState } from "react"; import { Button, KVRow, KeyValue } from "@clawtool/design-system"; -import { App, type Brand, type UpdateInfo } from "@clawtool/bridge"; +import { App, Win, openURL, type Brand, type UpdateInfo } from "@clawtool/bridge"; import styles from "../App.module.css"; +type Phase = "checking" | "uptodate" | "available" | "installing" | "done" | "error"; + +// Render GitHub-style release notes (a markdown subset) into headings + bullets. +function renderNotes(notes: string): ReactNode { + const lines = notes.split("\n"); + const out: ReactNode[] = []; + lines.forEach((raw, i) => { + const line = raw.trim(); + if (!line) return; + const heading = line.match(/^#{1,6}\s+(.*)$/); + if (heading) { + out.push( +
          + {heading[1]} +
          , + ); + return; + } + const bullet = line.match(/^[-*]\s+(.*)$/); + if (bullet) { + out.push( +
          + {bullet[1]} +
          , + ); + return; + } + out.push( +
          + {line} +
          , + ); + }); + return out; +} + export function Updates({ brand }: { brand: Brand }) { const [info, setInfo] = useState({}); - const [status, setStatus] = useState("checking…"); - const [busy, setBusy] = useState(false); + const [phase, setPhase] = useState("checking"); async function check() { - setStatus("checking…"); + setPhase("checking"); const u = await App.checkUpdate(); setInfo(u); - if (u.ok === false) setStatus("check failed"); - else setStatus(u.update_available ? `v${u.latest} available` : "up to date"); + if (u.ok === false) setPhase("error"); + else setPhase(u.update_available ? "available" : "uptodate"); } useEffect(() => { @@ -21,29 +56,103 @@ export function Updates({ brand }: { brand: Brand }) { }, []); async function install() { - setBusy(true); + setPhase("installing"); const r = await App.installUpdate(); - setStatus(r.ok ? `updated — restart ${brand.name} to finish` : "update failed"); - setBusy(false); + setPhase(r.ok ? "done" : "error"); + } + + const statusText: Record = { + checking: "Checking for updates…", + uptodate: "Up to date", + available: `v${info.latest} available`, + installing: "Installing…", + done: `Updated — restart ${brand.name} to finish`, + error: "Check failed", + }; + + function actionButton() { + switch (phase) { + case "checking": + return ( + + ); + case "available": + return ( + + ); + case "installing": + return ( + + ); + case "done": + return ( + + ); + case "error": + return ( + + ); + default: + return ( + + ); + } } + const notes = (info.release_notes ?? "").trim(); + return ( <> -

          Updates

          -
          {brand.name} checks for a new version each time it launches.
          +
          +
          +

          Updates

          +
          {brand.name} checks for a new version each time it launches.
          +
          +
          {actionButton()}
          +
          +
          {info.current ? `v${info.current}` : "—"} {info.latest ? `v${info.latest}` : "—"} - {status} + {statusText[phase]}
          -
          - - -
          + + {phase !== "checking" ? ( +
          +
          +
          {info.latest ? `What's new in v${info.latest}` : "What's new"}
          + {info.url ? ( + + ) : null} +
          + {notes ? ( +
          {renderNotes(notes)}
          + ) : ( +
          + {info.latest + ? `Release notes for v${info.latest} aren't published yet.` + : "No release information available."} +
          + )} +
          + ) : null} ); } diff --git a/desktop/packages/bridge/src/types.ts b/desktop/packages/bridge/src/types.ts index c6372ce..e6e6b2c 100644 --- a/desktop/packages/bridge/src/types.ts +++ b/desktop/packages/bridge/src/types.ts @@ -50,6 +50,8 @@ export type UpdateInfo = { found?: boolean; ok?: boolean; error?: string; + release_notes?: string; + url?: string; }; export type AgentSkill = { id?: string; name?: string; description?: string }; diff --git a/desktop/packages/design-system/src/components/GithubIcon.tsx b/desktop/packages/design-system/src/components/GithubIcon.tsx new file mode 100644 index 0000000..81181e5 --- /dev/null +++ b/desktop/packages/design-system/src/components/GithubIcon.tsx @@ -0,0 +1,12 @@ +import { siGithub } from "simple-icons"; + +// GitHub mark from Simple Icons (the licensed brand-icon library; GitHub +// permits redistribution). Rendered in currentColor for "view our repo on +// GitHub" links — nominative use. +export function GithubIcon({ size = 18 }: { size?: number }) { + return ( + + + + ); +} diff --git a/desktop/packages/design-system/src/components/KeyValue.module.css b/desktop/packages/design-system/src/components/KeyValue.module.css index 9b089a8..a731f0e 100644 --- a/desktop/packages/design-system/src/components/KeyValue.module.css +++ b/desktop/packages/design-system/src/components/KeyValue.module.css @@ -1,4 +1,4 @@ -.kv { max-width: 540px; } +.kv { width: 100%; } .row { display: flex; justify-content: space-between; diff --git a/desktop/packages/design-system/src/index.ts b/desktop/packages/design-system/src/index.ts index 68e3044..36ecd41 100644 --- a/desktop/packages/design-system/src/index.ts +++ b/desktop/packages/design-system/src/index.ts @@ -6,6 +6,7 @@ export { Metric, MetricStrip } from "./components/Metric"; export { SectionHeader } from "./components/Section"; export { AgentRow, List, EmptyRow } from "./components/AgentRow"; export { AgentIcon } from "./components/AgentIcon"; +export { GithubIcon } from "./components/GithubIcon"; export { TitleBar, type WindowControls } from "./components/TitleBar"; export { Sidebar, NavItem } from "./components/Sidebar"; export { Switch } from "./components/Switch"; From 7bddf0b0f922df5aae869a24b8205159cf036f51 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 03:26:12 +0300 Subject: [PATCH 29/86] fix(desktop): clip horizontal overflow so the closed side pane can't be scrolled into view The content scroll container only set overflow-y, which makes overflow-x compute to auto; the off-canvas side pane (translateX(100%)) then became horizontally scrollable on Agents and Network. Explicitly hide overflow-x. --- desktop/apps/app-ui/src/App.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/apps/app-ui/src/App.module.css b/desktop/apps/app-ui/src/App.module.css index 59c5722..c2e6bbc 100644 --- a/desktop/apps/app-ui/src/App.module.css +++ b/desktop/apps/app-ui/src/App.module.css @@ -1,6 +1,6 @@ .shell { display: flex; flex-direction: column; height: 100vh; } .body { display: flex; flex: 1; min-height: 0; } -.content { flex: 1; min-width: 0; overflow-y: auto; padding: 30px 36px; display: flex; flex-direction: column; position: relative; } +.content { flex: 1; min-width: 0; overflow-x: hidden; overflow-y: auto; padding: 30px 36px; display: flex; flex-direction: column; position: relative; } /* One consistent content column so every view uses the same full width. */ .page { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; } From 59a39326d20888d412db3ba8443f53bc8e6e7148 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 03:47:35 +0300 Subject: [PATCH 30/86] feat(desktop): keep-alive views so switching tabs feels native, not reloaded Each view used to unmount on navigation and re-fetch on remount, so every tab switch flashed an empty re-load. Keep all views mounted and toggle visibility instead; views now refresh in the background only while active (stale-while-revalidate), and the Updates check is lazy on first open so it doesn't hit the release API at launch. State and scroll position persist across tab switches. --- desktop/apps/app-ui/src/App.tsx | 25 ++++++++++++++++------ desktop/apps/app-ui/src/views/Agents.tsx | 5 +++-- desktop/apps/app-ui/src/views/Home.tsx | 6 +++--- desktop/apps/app-ui/src/views/Network.tsx | 5 +++-- desktop/apps/app-ui/src/views/Settings.tsx | 2 +- desktop/apps/app-ui/src/views/Updates.tsx | 12 +++++++---- 6 files changed, 37 insertions(+), 18 deletions(-) diff --git a/desktop/apps/app-ui/src/App.tsx b/desktop/apps/app-ui/src/App.tsx index bc54ff1..182b563 100644 --- a/desktop/apps/app-ui/src/App.tsx +++ b/desktop/apps/app-ui/src/App.tsx @@ -30,6 +30,7 @@ export function App() { }; const ic = { size: 16, strokeWidth: 1.8 }; + const hidden = { display: "none" } as const; return (
          @@ -50,12 +51,24 @@ export function App() { } label="Settings" active={view === "settings"} onClick={() => setView("settings")} />
          -
          - {view === "home" ? setView(v as View)} /> : null} - {view === "agents" ? : null} - {view === "network" ? : null} - {view === "updates" ? : null} - {view === "settings" ? : null} + {/* Keep every view mounted (native-app keep-alive): toggle visibility + instead of unmounting, so state + scroll persist and switching + tabs never flashes an empty re-load. Views refresh in the + background only while active. */} +
          + setView(v as View)} /> +
          +
          + +
          +
          + +
          +
          + +
          +
          +
          diff --git a/desktop/apps/app-ui/src/views/Agents.tsx b/desktop/apps/app-ui/src/views/Agents.tsx index 8f902c4..1cb92a0 100644 --- a/desktop/apps/app-ui/src/views/Agents.tsx +++ b/desktop/apps/app-ui/src/views/Agents.tsx @@ -39,7 +39,7 @@ function metaFor(a: Agent): string { return parts.join(" · "); } -export function Agents({ brand }: { brand: Brand }) { +export function Agents({ brand, active }: { brand: Brand; active: boolean }) { const [agents, setAgents] = useState([]); const [card, setCard] = useState(null); const [busy, setBusy] = useState(""); @@ -55,10 +55,11 @@ export function Agents({ brand }: { brand: Brand }) { } useEffect(() => { + if (!active) return; load(); const t = setInterval(load, 5000); return () => clearInterval(t); - }, []); + }, [active]); async function run(action: "connect" | "disconnect" | "install", a: Agent) { setBusy(a.instance); diff --git a/desktop/apps/app-ui/src/views/Home.tsx b/desktop/apps/app-ui/src/views/Home.tsx index bd5cc3c..ff5041a 100644 --- a/desktop/apps/app-ui/src/views/Home.tsx +++ b/desktop/apps/app-ui/src/views/Home.tsx @@ -3,7 +3,7 @@ import { Button, Metric, MetricStrip, StatusDot } from "@clawtool/design-system" import { App, type Brand } from "@clawtool/bridge"; import styles from "../App.module.css"; -export function Home({ brand, onNavigate }: { brand: Brand; onNavigate: (v: string) => void }) { +export function Home({ brand, active, onNavigate }: { brand: Brand; active: boolean; onNavigate: (v: string) => void }) { const [online, setOnline] = useState(false); const [agents, setAgents] = useState(0); const [peers, setPeers] = useState(0); @@ -24,8 +24,8 @@ export function Home({ brand, onNavigate }: { brand: Brand; onNavigate: (v: stri } useEffect(() => { - load(); - }, []); + if (active) load(); + }, [active]); return ( <> diff --git a/desktop/apps/app-ui/src/views/Network.tsx b/desktop/apps/app-ui/src/views/Network.tsx index 6d3ce39..8e71be4 100644 --- a/desktop/apps/app-ui/src/views/Network.tsx +++ b/desktop/apps/app-ui/src/views/Network.tsx @@ -41,7 +41,7 @@ async function copyText(text: string): Promise { } } -export function Network({ brand }: { brand: Brand }) { +export function Network({ brand, active }: { brand: Brand; active: boolean }) { const [banner, setBanner] = useState(""); const [peers, setPeers] = useState([]); const [hasKey, setHasKey] = useState(false); @@ -77,11 +77,12 @@ export function Network({ brand }: { brand: Brand }) { } useEffect(() => { + if (!active) return; loadCircle(); loadNetwork(); const t = setInterval(loadNetwork, 5000); return () => clearInterval(t); - }, []); + }, [active]); async function generate() { const r = await App.circleGenerate(); diff --git a/desktop/apps/app-ui/src/views/Settings.tsx b/desktop/apps/app-ui/src/views/Settings.tsx index 9749f32..540a2ac 100644 --- a/desktop/apps/app-ui/src/views/Settings.tsx +++ b/desktop/apps/app-ui/src/views/Settings.tsx @@ -3,7 +3,7 @@ import { Button, GithubIcon, KVRow, KeyValue, SectionHeader } from "@clawtool/de import { App, openURL, type Brand } from "@clawtool/bridge"; import styles from "../App.module.css"; -export function Settings({ brand }: { brand: Brand }) { +export function Settings({ brand }: { brand: Brand; active?: boolean }) { const [doctor, setDoctor] = useState(""); const [busy, setBusy] = useState(false); diff --git a/desktop/apps/app-ui/src/views/Updates.tsx b/desktop/apps/app-ui/src/views/Updates.tsx index 620be1a..71c3248 100644 --- a/desktop/apps/app-ui/src/views/Updates.tsx +++ b/desktop/apps/app-ui/src/views/Updates.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; import { Button, KVRow, KeyValue } from "@clawtool/design-system"; import { App, Win, openURL, type Brand, type UpdateInfo } from "@clawtool/bridge"; import styles from "../App.module.css"; @@ -39,9 +39,10 @@ function renderNotes(notes: string): ReactNode { return out; } -export function Updates({ brand }: { brand: Brand }) { +export function Updates({ brand, active }: { brand: Brand; active: boolean }) { const [info, setInfo] = useState({}); const [phase, setPhase] = useState("checking"); + const checkedOnce = useRef(false); async function check() { setPhase("checking"); @@ -52,8 +53,11 @@ export function Updates({ brand }: { brand: Brand }) { } useEffect(() => { - check(); - }, []); + if (active && !checkedOnce.current) { + checkedOnce.current = true; + check(); + } + }, [active]); async function install() { setPhase("installing"); From 34669e9252709e966da72196f7b4a7f3acf6cabf Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 04:05:48 +0300 Subject: [PATCH 31/86] feat(a2a): per-device certificate identity (DeviceID) for keypair-based pairing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First phase of replacing the shared-secret circle key with Syncthing-style per-device trust. Each install now generates and persists a self-signed ECDSA cert once; DeviceID is the base32 SHA-256 of the cert DER — a stable, unforgeable public-key fingerprint. Proving possession of the private key (via mutual TLS, landing in later phases) will authenticate peers, so no shared secret has to change hands. Purely additive: nothing consumes the identity yet, so existing behavior is unchanged. --- internal/a2a/devicecert.go | 162 ++++++++++++++++++++++++++++++++ internal/a2a/devicecert_test.go | 58 ++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 internal/a2a/devicecert.go create mode 100644 internal/a2a/devicecert_test.go diff --git a/internal/a2a/devicecert.go b/internal/a2a/devicecert.go new file mode 100644 index 0000000..eaca7dc --- /dev/null +++ b/internal/a2a/devicecert.go @@ -0,0 +1,162 @@ +// Package a2a — per-device cryptographic identity (Tier-2 trust). +// +// The legacy model authenticated cross-device traffic with a shared +// secret (the circle key) and anchored trust on InstallID, a plain +// UUID — which is neither secret nor unforgeable, so any LAN device +// that saw a fingerprint could impersonate it. This file replaces that +// anchor with a real keypair, mirroring Syncthing's model: +// +// - Each install generates a self-signed certificate once and +// persists it (device-cert.pem + device-key.pem, mode 0600). +// - The DeviceID is the SHA-256 of the certificate's DER bytes, +// base32-encoded — a stable, unforgeable fingerprint of the public +// key. Proving you hold the matching private key (via mutual TLS) +// is what authenticates a peer; no shared secret changes hands. +// +// This file is intentionally identity-only: cert generation, on-disk +// persistence, and fingerprint computation. The TLS listener/client +// that present this cert, and the trust gate that checks a presented +// fingerprint against the PairingStore, live in the server layer. +package a2a + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base32" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "sync" + "time" + + "github.com/cogitave/clawtool/internal/atomicfile" + "github.com/cogitave/clawtool/internal/xdg" +) + +func deviceCertPath() string { return filepath.Join(xdg.ConfigDir(), "device-cert.pem") } +func deviceKeyPath() string { return filepath.Join(xdg.ConfigDir(), "device-key.pem") } + +// b32 encodes fingerprints without padding so the DeviceID is a clean +// uppercase token (RFC 4648 alphabet, matching Syncthing's choice). +var b32 = base32.StdEncoding.WithPadding(base32.NoPadding) + +// deviceCert caches the loaded/created certificate so repeated callers +// (listener, client, DeviceID) don't re-read disk or regenerate. +var ( + deviceCertMu sync.Mutex + deviceCertOnce *tls.Certificate + deviceCertErr error +) + +// DeviceCertificate returns this install's long-term TLS certificate, +// generating and persisting one on first use. Cached process-wide. +func DeviceCertificate() (tls.Certificate, error) { + deviceCertMu.Lock() + defer deviceCertMu.Unlock() + if deviceCertOnce != nil || deviceCertErr != nil { + if deviceCertOnce != nil { + return *deviceCertOnce, nil + } + return tls.Certificate{}, deviceCertErr + } + cert, err := loadOrCreateDeviceCert() + if err != nil { + deviceCertErr = err + return tls.Certificate{}, err + } + deviceCertOnce = &cert + return cert, nil +} + +// DeviceID returns the stable fingerprint of this install's public key +// — the SHA-256 of its certificate DER, base32-encoded. Empty string +// only if cert generation itself fails (effectively impossible on a +// healthy host). This is the identity peers pair against. +func DeviceID() string { + cert, err := DeviceCertificate() + if err != nil || len(cert.Certificate) == 0 { + return "" + } + return Fingerprint(cert.Certificate[0]) +} + +// Fingerprint computes the DeviceID-style fingerprint of any raw +// certificate DER — used on the receiving side to identify a peer from +// the client cert it presented during the TLS handshake. +func Fingerprint(certDER []byte) string { + sum := sha256.Sum256(certDER) + return b32.EncodeToString(sum[:]) +} + +// resetDeviceCertCacheForTest clears the process cache so a test can +// point XDG at a fresh temp dir and exercise generation. Never called +// in production. +func resetDeviceCertCacheForTest() { + deviceCertMu.Lock() + defer deviceCertMu.Unlock() + deviceCertOnce = nil + deviceCertErr = nil +} + +func loadOrCreateDeviceCert() (tls.Certificate, error) { + certPEM, certErr := os.ReadFile(deviceCertPath()) + keyPEM, keyErr := os.ReadFile(deviceKeyPath()) + if certErr == nil && keyErr == nil { + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err == nil { + return cert, nil + } + // A corrupt/half-written pair shouldn't wedge the daemon + // forever — regenerate. The peer re-prompts pairing (the + // fingerprint changed), which is the same recovery path as a + // lost InstallID: annoying once, never broken. + } + return generateDeviceCert() +} + +func generateDeviceCert() (tls.Certificate, error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, fmt.Errorf("generate device key: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, fmt.Errorf("generate serial: %w", err) + } + // Long-lived self-signed cert: the fingerprint IS the identity, so + // expiry only forces re-pairing churn with no security benefit. + // 100 years matches Syncthing's effectively-permanent device cert. + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "clawtool"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().AddDate(100, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, fmt.Errorf("create device cert: %w", err) + } + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return tls.Certificate{}, fmt.Errorf("marshal device key: %w", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + // Persist best-effort: a write failure still returns a usable + // in-memory cert for this run (a later run regenerates + re-prompts). + _ = atomicfile.WriteFileMkdir(deviceCertPath(), certPEM, 0o600, 0o700) + _ = atomicfile.WriteFileMkdir(deviceKeyPath(), keyPEM, 0o600, 0o700) + + return tls.X509KeyPair(certPEM, keyPEM) +} diff --git a/internal/a2a/devicecert_test.go b/internal/a2a/devicecert_test.go new file mode 100644 index 0000000..ed8c91e --- /dev/null +++ b/internal/a2a/devicecert_test.go @@ -0,0 +1,58 @@ +package a2a + +import ( + "os" + "testing" +) + +func TestDeviceID_StableAndPersisted(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + resetDeviceCertCacheForTest() + + first := DeviceID() + if first == "" { + t.Fatal("DeviceID returned empty") + } + // Stable within the process (cached). + if second := DeviceID(); second != first { + t.Errorf("DeviceID not stable in-process: %q != %q", first, second) + } + // Stable across a fresh load from the persisted cert. + resetDeviceCertCacheForTest() + if reloaded := DeviceID(); reloaded != first { + t.Errorf("DeviceID not stable across reload: %q != %q", reloaded, first) + } + if _, err := os.Stat(deviceCertPath()); err != nil { + t.Errorf("device cert not persisted: %v", err) + } + if _, err := os.Stat(deviceKeyPath()); err != nil { + t.Errorf("device key not persisted: %v", err) + } +} + +func TestDeviceID_DistinctPerInstall(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + resetDeviceCertCacheForTest() + a := DeviceID() + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + resetDeviceCertCacheForTest() + b := DeviceID() + + if a == b { + t.Errorf("two installs share a DeviceID: %q", a) + } +} + +func TestFingerprint_MatchesDeviceID(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + resetDeviceCertCacheForTest() + + cert, err := DeviceCertificate() + if err != nil { + t.Fatalf("DeviceCertificate: %v", err) + } + if got := Fingerprint(cert.Certificate[0]); got != DeviceID() { + t.Errorf("Fingerprint(cert) = %q, want DeviceID %q", got, DeviceID()) + } +} From 400b21c397e69bc6152c2df1cfc21781997c119e Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 04:11:55 +0300 Subject: [PATCH 32/86] feat(server): parallel mTLS peer listener for cross-device trust MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Tier-2 trust rewrite. ServeHTTP gains an optional PeerListen address: when set it starts an mTLS HTTPS listener alongside the existing loopback HTTP listener, presenting this install's device certificate and requesting the peer's (RequestClientCert, not Require — the handshake must complete for an unpaired peer so first-contact pairing can read its fingerprint and prompt). Each side learns the other's cert fingerprint; no shared secret changes hands. The local loopback surface (claude/codex MCP over 127.0.0.1) is untouched, and a failed peer listener is logged, never fatal. Auth still flows through the existing middleware — swapping it to fingerprint-based trust is the next phase. --- internal/a2a/devicecert.go | 9 +-- internal/a2a/devicecert_test.go | 10 +-- internal/server/http.go | 20 ++++++ internal/server/peer_listener.go | 69 +++++++++++++++++++ internal/server/peer_listener_test.go | 96 +++++++++++++++++++++++++++ 5 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 internal/server/peer_listener.go create mode 100644 internal/server/peer_listener_test.go diff --git a/internal/a2a/devicecert.go b/internal/a2a/devicecert.go index eaca7dc..5300b12 100644 --- a/internal/a2a/devicecert.go +++ b/internal/a2a/devicecert.go @@ -95,10 +95,11 @@ func Fingerprint(certDER []byte) string { return b32.EncodeToString(sum[:]) } -// resetDeviceCertCacheForTest clears the process cache so a test can -// point XDG at a fresh temp dir and exercise generation. Never called -// in production. -func resetDeviceCertCacheForTest() { +// ResetDeviceCertCacheForTest clears the process cache so a test can +// point XDG at a fresh temp dir and exercise generation. Tests in other +// packages need it to mint a distinct identity; production never calls +// it (sibling of SetGlobalPairingStore). +func ResetDeviceCertCacheForTest() { deviceCertMu.Lock() defer deviceCertMu.Unlock() deviceCertOnce = nil diff --git a/internal/a2a/devicecert_test.go b/internal/a2a/devicecert_test.go index ed8c91e..9b2ebcd 100644 --- a/internal/a2a/devicecert_test.go +++ b/internal/a2a/devicecert_test.go @@ -7,7 +7,7 @@ import ( func TestDeviceID_StableAndPersisted(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - resetDeviceCertCacheForTest() + ResetDeviceCertCacheForTest() first := DeviceID() if first == "" { @@ -18,7 +18,7 @@ func TestDeviceID_StableAndPersisted(t *testing.T) { t.Errorf("DeviceID not stable in-process: %q != %q", first, second) } // Stable across a fresh load from the persisted cert. - resetDeviceCertCacheForTest() + ResetDeviceCertCacheForTest() if reloaded := DeviceID(); reloaded != first { t.Errorf("DeviceID not stable across reload: %q != %q", reloaded, first) } @@ -32,11 +32,11 @@ func TestDeviceID_StableAndPersisted(t *testing.T) { func TestDeviceID_DistinctPerInstall(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - resetDeviceCertCacheForTest() + ResetDeviceCertCacheForTest() a := DeviceID() t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - resetDeviceCertCacheForTest() + ResetDeviceCertCacheForTest() b := DeviceID() if a == b { @@ -46,7 +46,7 @@ func TestDeviceID_DistinctPerInstall(t *testing.T) { func TestFingerprint_MatchesDeviceID(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - resetDeviceCertCacheForTest() + ResetDeviceCertCacheForTest() cert, err := DeviceCertificate() if err != nil { diff --git a/internal/server/http.go b/internal/server/http.go index c992529..d6f4a36 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -49,6 +49,13 @@ type HTTPOptions struct { TokenFile string // path to a 0600 file containing the bearer token. Refused if missing/empty unless NoAuth is set. MCPHTTP bool // when true, mount the MCP toolset at /mcp via mcp-go's Streamable HTTP transport. + // PeerListen, when non-empty, starts a parallel mTLS listener on + // that address for cross-device peers (Tier-2 trust). It presents + // this install's device certificate and requests the peer's, so + // each side learns the other's fingerprint. The main loopback + // listener (Listen) is unaffected. Empty = no peer surface. + PeerListen string + // NoAuth runs the listener without bearer-token enforcement. The // shared local daemon flips this on by default — the operator's // machine is the trust boundary, codex / gemini hit /mcp over @@ -344,6 +351,19 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error { } }() + // Cross-device mTLS listener (Tier-2). Runs parallel to the + // loopback listener; the main srv below keeps blocking as before. + // Errors are logged, never fatal — a failed peer listener must not + // take down the local gateway that claude / codex depend on. + if strings.TrimSpace(opts.PeerListen) != "" { + go func() { + if err := servePeerListener(ctx, opts.PeerListen, mux); err != nil { + fmt.Fprintf(os.Stderr, "clawtool: peer mTLS listener on %s stopped: %v\n", opts.PeerListen, err) + } + }() + fmt.Fprintf(os.Stderr, "clawtool: peer mTLS listener on %s (device %s)\n", opts.PeerListen, a2a.DeviceID()) + } + listenErr := srv.ListenAndServe() if listenErr != nil && !errors.Is(listenErr, http.ErrServerClosed) { return fmt.Errorf("listen %s: %w", opts.Listen, listenErr) diff --git a/internal/server/peer_listener.go b/internal/server/peer_listener.go new file mode 100644 index 0000000..493ea3d --- /dev/null +++ b/internal/server/peer_listener.go @@ -0,0 +1,69 @@ +// Cross-device mTLS listener (Tier-2 trust, Phase 2). +// +// The loopback listener (ServeHTTP's main srv) stays plain HTTP + bearer +// token for local MCP clients (claude / codex over 127.0.0.1). This file +// adds the SEPARATE surface remote devices talk to: an HTTPS listener that +// presents this install's device certificate and asks the peer to present +// its own. After the handshake each side knows the other's certificate +// fingerprint (a2a.Fingerprint of the presented cert) — the unforgeable +// identity later phases gate trust on. No shared secret is involved. +// +// ClientAuth is RequestClientCert, not RequireAndVerifyClientCert: the +// handshake must COMPLETE even for a peer we've never paired with, so the +// daemon can read its fingerprint and surface a pairing prompt. Rejecting +// the unknown peer at the TLS layer would make first-contact pairing +// impossible. The trust decision is the auth layer's job (Phase 3), keyed +// on the presented fingerprint — not the handshake's. +package server + +import ( + "context" + "crypto/tls" + "errors" + "net/http" + "time" + + "github.com/cogitave/clawtool/internal/a2a" +) + +// peerTLSConfig builds the server-side mTLS config for the cross-device +// listener: serve this device's cert, request (don't require) the peer's. +func peerTLSConfig() (*tls.Config, error) { + cert, err := a2a.DeviceCertificate() + if err != nil { + return nil, err + } + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.RequestClientCert, + MinVersion: tls.VersionTLS12, + }, nil +} + +// servePeerListener runs an mTLS HTTP server bound to addr, serving h until +// ctx is cancelled. Returns nil on graceful shutdown. Runs parallel to the +// loopback listener — the caller starts it in its own goroutine so the main +// listener keeps blocking as before. +func servePeerListener(ctx context.Context, addr string, h http.Handler) error { + tlsCfg, err := peerTLSConfig() + if err != nil { + return err + } + srv := &http.Server{ + Addr: addr, + Handler: h, + TLSConfig: tlsCfg, + ReadHeaderTimeout: 10 * time.Second, + } + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + // Certs come from TLSConfig, so the file arguments are empty. + if err := srv.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/internal/server/peer_listener_test.go b/internal/server/peer_listener_test.go new file mode 100644 index 0000000..5d77d7a --- /dev/null +++ b/internal/server/peer_listener_test.go @@ -0,0 +1,96 @@ +package server + +import ( + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cogitave/clawtool/internal/a2a" +) + +// TestPeerTLSConfig_ServesDeviceCertAndRequestsClient stands up an httptest +// TLS server using the real peerTLSConfig and verifies the security-relevant +// handshake behavior: the server presents this device's certificate (cert +// fingerprint == DeviceID), and it receives the client's certificate so a +// later phase can identify the peer. +func TestPeerTLSConfig_ServesDeviceCertAndRequestsClient(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + wantID := a2a.DeviceID() + if wantID == "" { + t.Fatal("DeviceID empty — cert generation failed") + } + + var gotClientFP string + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { + gotClientFP = a2a.Fingerprint(r.TLS.PeerCertificates[0].Raw) + } + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + }) + + cfg, err := peerTLSConfig() + if err != nil { + t.Fatalf("peerTLSConfig: %v", err) + } + if cfg.ClientAuth != tls.RequestClientCert { + t.Errorf("ClientAuth = %v, want RequestClientCert (handshake must complete for unpaired peers)", cfg.ClientAuth) + } + + srv := httptest.NewUnstartedServer(h) + srv.TLS = cfg + srv.StartTLS() + defer srv.Close() + + // A client identity distinct from the server's, to prove the server + // actually receives a different fingerprint over the wire. + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + a2a.ResetDeviceCertCacheForTest() + clientCert, err := a2a.DeviceCertificate() + if err != nil { + t.Fatalf("client DeviceCertificate: %v", err) + } + clientFP := a2a.DeviceID() + + var serverFP string + client := &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{clientCert}, + // TOFU: don't validate against a CA (self-signed). Capture + // the served leaf so we can assert its fingerprint. + InsecureSkipVerify: true, //nolint:gosec // self-signed pinning model; fingerprint asserted below + VerifyConnection: func(cs tls.ConnectionState) error { + if len(cs.PeerCertificates) > 0 { + serverFP = a2a.Fingerprint(cs.PeerCertificates[0].Raw) + } + return nil + }, + }, + }, + } + + resp, err := client.Get(srv.URL) + if err != nil { + t.Fatalf("mTLS GET: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + if serverFP != wantID { + t.Errorf("served cert fingerprint = %q, want DeviceID %q", serverFP, wantID) + } + if gotClientFP == "" { + t.Error("server did not receive the client certificate") + } + if gotClientFP != clientFP { + t.Errorf("server saw client fingerprint %q, want %q", gotClientFP, clientFP) + } + if serverFP == gotClientFP { + t.Error("server and client fingerprints identical — distinct identities expected") + } +} From 02be0ce024078a7d5f6a5bc5cc667aa28c141e67 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 04:16:21 +0300 Subject: [PATCH 33/86] feat(server): fingerprint-trust auth gate for mTLS peers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Tier-2 rewrite. circleOrBearer (the gate on the peer-facing endpoints /v1/agents and /v1/relay) now authorizes a request arriving over TLS by its client-cert fingerprint: approved in the PairingStore → allowed; unknown → recorded as a pending request (surfacing the approve/deny prompt) and answered with pairing_required + the pairing code, not a flat 401; denied → refused. Checked before the no-auth shortcut so the gate holds even under a loopback no-auth daemon. The fingerprint replaces the shared circle key as the credential. Bearer-only endpoints (incl. /mcp code execution) are untouched, so an approved peer keeps exactly the read+relay scope the circle key had — never raw code-exec. The non-TLS path is unchanged; existing circle-key tests still pass. --- internal/a2a/devicecert.go | 6 ++ internal/server/http.go | 34 ++++++++++ internal/server/peer_auth_test.go | 104 ++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 internal/server/peer_auth_test.go diff --git a/internal/a2a/devicecert.go b/internal/a2a/devicecert.go index 5300b12..c002cc7 100644 --- a/internal/a2a/devicecert.go +++ b/internal/a2a/devicecert.go @@ -43,6 +43,12 @@ import ( func deviceCertPath() string { return filepath.Join(xdg.ConfigDir(), "device-cert.pem") } func deviceKeyPath() string { return filepath.Join(xdg.ConfigDir(), "device-key.pem") } +// DeviceNameHeader carries the initiating device's human label on a peer +// request, so the receiving operator's approval prompt shows "laptop wants +// to pair" rather than a bare fingerprint. Advisory only — the fingerprint +// from the TLS client cert is what trust is keyed on, never this header. +const DeviceNameHeader = "X-Clawtool-Device-Name" + // b32 encodes fingerprints without padding so the DeviceID is a clean // uppercase token (RFC 4648 alphabet, matching Syncthing's choice). var b32 = base32.StdEncoding.WithPadding(base32.NoPadding) diff --git a/internal/server/http.go b/internal/server/http.go index d6f4a36..d947d26 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -482,6 +482,40 @@ func circleOrBearer(token string, trustLoopback bool) func(http.Handler) http.Ha exp := []byte(token) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // mTLS peer surface (Tier-2): a request arriving over TLS with a + // client certificate is a remote device. It is authorized ONLY + // if its certificate fingerprint has been approved in the + // PairingStore — checked FIRST so this gate holds even when the + // loopback daemon runs in no-auth mode. An unknown fingerprint + // isn't a flat reject: we record it as a pending request (which + // surfaces the approve/deny prompt on this machine) and answer + // with pairing_required so the caller knows to wait for approval. + // The shared circle key plays no part here — possession of the + // private key behind an approved fingerprint is the credential. + if r.TLS != nil { + if len(r.TLS.PeerCertificates) == 0 { + writeJSON(w, http.StatusForbidden, map[string]any{ + "error": "peer access requires a client certificate", + }) + return + } + fp := a2a.Fingerprint(r.TLS.PeerCertificates[0].Raw) + store := a2a.GlobalPairingStore() + if store.IsApproved(fp) { + next.ServeHTTP(w, r) + return + } + rec, _, _ := store.Observe(fp, r.Header.Get(a2a.DeviceNameHeader), r.RemoteAddr) + resp := map[string]any{ + "pairing_required": true, + "fingerprint": fp, + } + if rec != nil { + resp["code"] = rec.Code + } + writeJSON(w, http.StatusForbidden, resp) + return + } // No bearer configured ⇒ local NoAuth daemon (loopback is the // trust boundary): allow, exactly like authMiddleware. if len(exp) == 0 { diff --git a/internal/server/peer_auth_test.go b/internal/server/peer_auth_test.go new file mode 100644 index 0000000..d44654a --- /dev/null +++ b/internal/server/peer_auth_test.go @@ -0,0 +1,104 @@ +package server + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cogitave/clawtool/internal/a2a" +) + +// peerRequest builds a request that looks like it arrived over the mTLS peer +// listener carrying the given client certificate. +func peerRequest(t *testing.T, cert tls.Certificate) *http.Request { + t.Helper() + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatalf("parse cert: %v", err) + } + req := httptest.NewRequest(http.MethodPost, "/v1/relay", nil) + req.RemoteAddr = "192.168.1.9:5555" + req.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf}} + return req +} + +func TestCircleOrBearer_PeerFingerprintTrust(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + a2a.ResetDeviceCertCacheForTest() + cert, err := a2a.DeviceCertificate() + if err != nil { + t.Fatalf("DeviceCertificate: %v", err) + } + fp := a2a.DeviceID() + + // Isolated pairing store so the test never touches real state. + store, err := a2a.LoadPairingStore() // empty (temp XDG) + if err != nil { + t.Fatalf("load store: %v", err) + } + a2a.SetGlobalPairingStore(store) + t.Cleanup(func() { a2a.SetGlobalPairingStore(nil) }) + + reached := false + h := circleOrBearer("local-token", true)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + })) + + // 1. Unknown fingerprint → 403 pairing_required, and a pending record + // is created so the operator gets a prompt. + rec := httptest.NewRecorder() + h.ServeHTTP(rec, peerRequest(t, cert)) + if rec.Code != http.StatusForbidden { + t.Fatalf("unknown peer: status = %d, want 403", rec.Code) + } + if reached { + t.Fatal("unknown peer reached the handler — must be gated") + } + var body map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &body) + if body["pairing_required"] != true { + t.Errorf("unknown peer: body = %v, want pairing_required", body) + } + if got := store.List(); len(got) != 1 || got[0].Fingerprint != fp { + t.Errorf("unknown peer: pending record not created: %+v", got) + } + + // 2. Approved fingerprint → handler reached. + if _, err := store.Approve(fp); err != nil { + t.Fatalf("approve: %v", err) + } + reached = false + rec = httptest.NewRecorder() + h.ServeHTTP(rec, peerRequest(t, cert)) + if rec.Code != http.StatusOK || !reached { + t.Errorf("approved peer: status = %d reached = %v, want 200 + reached", rec.Code, reached) + } + + // 3. Denied fingerprint → gated again. + if _, err := store.Deny(fp); err != nil { + t.Fatalf("deny: %v", err) + } + reached = false + rec = httptest.NewRecorder() + h.ServeHTTP(rec, peerRequest(t, cert)) + if rec.Code != http.StatusForbidden || reached { + t.Errorf("denied peer: status = %d reached = %v, want 403 + not reached", rec.Code, reached) + } +} + +func TestCircleOrBearer_PeerWithoutClientCertRejected(t *testing.T) { + h := circleOrBearer("local-token", true)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + req := httptest.NewRequest(http.MethodPost, "/v1/relay", nil) + req.TLS = &tls.ConnectionState{} // TLS but no client cert + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403 for missing client cert", rec.Code) + } +} From 90df151cb5385327339367293983dfbb831730d7 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 04:23:19 +0300 Subject: [PATCH 34/86] feat(server): outbound mTLS peer client with fingerprint pinning Phase 4 of the Tier-2 rewrite. proxyPeerAgents and sendPairRequest now reach peers over mTLS instead of plain HTTP + circle-key header: the client presents this device's certificate, pins the peer to the DeviceID discovery advertised (trust-on-first-use when none is known yet), and sends the device-name header for the approval prompt. peerBaseURL resolves https. A 403 from the peer is mapped to pairing_required (approval pending) rather than the old not_in_circle. The obsolete circle-key proxy tests are rewritten to exercise the mTLS path (client cert presented, agents relayed, pairing pending). --- internal/server/circle_test.go | 78 ++++++++++++------------------- internal/server/peer_client.go | 80 ++++++++++++++++++++++++++++++++ internal/server/peers_handler.go | 75 +++++++++++------------------- 3 files changed, 135 insertions(+), 98 deletions(-) create mode 100644 internal/server/peer_client.go diff --git a/internal/server/circle_test.go b/internal/server/circle_test.go index 68398db..f87ed0b 100644 --- a/internal/server/circle_test.go +++ b/internal/server/circle_test.go @@ -1,6 +1,7 @@ package server import ( + "crypto/tls" "encoding/json" "io" "net/http" @@ -103,54 +104,31 @@ func TestCircleOrBearer_NoKeyConfiguredDeniesHeaderOnly(t *testing.T) { } } -// TestProxyPeerAgents_NeedsCircleKey: with no circle key on this device, -// the proxy returns needs_circle_key=true (a prompt, not an error) so -// the dashboard can guide the operator. -func TestProxyPeerAgents_NeedsCircleKey(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // no key - mux, reg, cleanup := newPeersTestMux(t, "tok") - defer cleanup() - srv := httptest.NewServer(mux) - defer srv.Close() - p, _ := reg.Register(a2a.RegisterInput{ - DisplayName: "remote", Backend: "clawtool-mdns", - Metadata: map[string]string{"agent_card_url": "http://192.168.1.50:52828/.well-known/agent-card.json"}, - }) - - resp, body := peersDo(t, srv, http.MethodGet, "/v1/peers/"+p.PeerID+"/agents", "tok", nil) - if resp.StatusCode != http.StatusOK { - t.Fatalf("no-key proxy = %d, want 200; body %s", resp.StatusCode, body) - } - var out map[string]any - if err := json.Unmarshal(body, &out); err != nil { - t.Fatal(err) - } - if out["needs_circle_key"] != true { - t.Errorf("expected needs_circle_key=true, got %v", out) - } -} - -// TestProxyPeerAgents_RelaysWithCircleKey: with a circle key set, the -// proxy forwards it to the peer and relays the peer's agents. The fake -// peer asserts it received the matching circle header. -func TestProxyPeerAgents_RelaysWithCircleKey(t *testing.T) { +// TestProxyPeerAgents_RelaysOverMTLS: the proxy reaches the peer over mTLS +// (presenting this device's cert + the device-name header) and relays the +// peer's agents. No shared secret involved. +func TestProxyPeerAgents_RelaysOverMTLS(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - if err := a2a.SaveCircleKey("shared-key"); err != nil { - t.Fatal(err) - } + a2a.ResetDeviceCertCacheForTest() - var gotHeader string - peerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotHeader = r.Header.Get(a2a.CircleKeyHeader) + var gotName string + var gotClientCert bool + peerSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotName = r.Header.Get(a2a.DeviceNameHeader) + gotClientCert = r.TLS != nil && len(r.TLS.PeerCertificates) > 0 w.Header().Set("Content-Type", "application/json") _, _ = io.WriteString(w, `{"agents":[{"instance":"codex1","family":"codex","callable":true}],"count":1}`) })) + // Request the client cert so we can prove we presented it. + peerSrv.TLS.ClientAuth = tls.RequestClientCert defer peerSrv.Close() mux, reg, cleanup := newPeersTestMux(t, "tok") defer cleanup() srv := httptest.NewServer(mux) defer srv.Close() + // No device_id in metadata ⇒ trust-on-first-use (accept the peer's + // self-signed httptest cert). p, _ := reg.Register(a2a.RegisterInput{ DisplayName: "remote", Backend: "clawtool-mdns", Metadata: map[string]string{"agent_card_url": peerSrv.URL + "/.well-known/agent-card.json"}, @@ -160,8 +138,11 @@ func TestProxyPeerAgents_RelaysWithCircleKey(t *testing.T) { if resp.StatusCode != http.StatusOK { t.Fatalf("proxy = %d, want 200; body %s", resp.StatusCode, body) } - if gotHeader != "shared-key" { - t.Errorf("peer received circle header %q, want shared-key", gotHeader) + if !gotClientCert { + t.Error("peer did not receive our client certificate over mTLS") + } + if gotName == "" { + t.Error("peer did not receive the device-name header") } var out map[string]any if err := json.Unmarshal(body, &out); err != nil { @@ -172,15 +153,14 @@ func TestProxyPeerAgents_RelaysWithCircleKey(t *testing.T) { } } -// TestProxyPeerAgents_NotInCircle: peer reachable but rejects the key → -// not_in_circle=true. -func TestProxyPeerAgents_NotInCircle(t *testing.T) { +// TestProxyPeerAgents_PairingRequired: a peer that hasn't approved this +// device yet answers 403 → the proxy surfaces pairing_required. +func TestProxyPeerAgents_PairingRequired(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - if err := a2a.SaveCircleKey("my-key"); err != nil { - t.Fatal(err) - } - peerSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusUnauthorized) + a2a.ResetDeviceCertCacheForTest() + + peerSrv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) })) defer peerSrv.Close() @@ -198,7 +178,7 @@ func TestProxyPeerAgents_NotInCircle(t *testing.T) { if err := json.Unmarshal(body, &out); err != nil { t.Fatal(err) } - if out["not_in_circle"] != true { - t.Errorf("expected not_in_circle=true, got %v", out) + if out["pairing_required"] != true { + t.Errorf("expected pairing_required=true, got %v", out) } } diff --git a/internal/server/peer_client.go b/internal/server/peer_client.go new file mode 100644 index 0000000..dcce4c7 --- /dev/null +++ b/internal/server/peer_client.go @@ -0,0 +1,80 @@ +// Outbound side of cross-device mTLS (Tier-2 trust, Phase 4). +// +// When this daemon reaches OUT to another device — fetching its agents, +// sending a pair request, relaying a message — it must (a) present this +// install's device certificate so the remote can identify and trust us by +// fingerprint, and (b) verify the remote is who discovery said it is. +// +// Verification is fingerprint pinning, not CA validation: device certs are +// self-signed, so there's no CA to chain to. If discovery already told us +// the peer's DeviceID we pin it (a mismatch = someone else answering that +// address = MITM, rejected). On true first contact we have no expected +// fingerprint yet, so we trust-on-first-use and capture what we saw — the +// human-confirmed pairing code (Phase 5) is what closes the MITM window. +package server + +import ( + "crypto/tls" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/cogitave/clawtool/internal/a2a" +) + +// peerExpectedFingerprint returns the DeviceID discovery advertised for this +// peer, or "" when unknown (first contact, pre-Phase-4b discovery). +func peerExpectedFingerprint(peer *a2a.Peer) string { + if peer == nil { + return "" + } + return peer.Metadata["device_id"] +} + +// peerHTTPClient builds an mTLS client that presents this device's cert and +// pins the server to expectFP (empty = trust-on-first-use). timeout bounds +// the whole exchange. +func peerHTTPClient(expectFP string, timeout time.Duration) (*http.Client, error) { + cert, err := a2a.DeviceCertificate() + if err != nil { + return nil, err + } + tlsCfg := &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + // Self-signed peer certs: skip the default CA chain check and pin + // by fingerprint in VerifyConnection instead. + InsecureSkipVerify: true, //nolint:gosec // fingerprint-pinned below + VerifyConnection: func(cs tls.ConnectionState) error { + if len(cs.PeerCertificates) == 0 { + return errors.New("peer presented no certificate") + } + got := a2a.Fingerprint(cs.PeerCertificates[0].Raw) + if expectFP != "" && got != expectFP { + return fmt.Errorf("peer fingerprint mismatch: got %s, expected %s", got, expectFP) + } + return nil + }, + } + return &http.Client{ + Timeout: timeout, + Transport: &http.Transport{TLSClientConfig: tlsCfg}, + }, nil +} + +// peerBaseURL resolves a peer's HTTPS base URL from what discovery recorded. +// The agent_card_url is authoritative (it carries the scheme + peer port); +// the bare address is an https fallback for older records. +func peerBaseURL(peer *a2a.Peer) string { + if cardURL := peer.Metadata["agent_card_url"]; cardURL != "" { + if i := strings.Index(cardURL, "/.well-known/"); i > 0 { + return cardURL[:i] + } + } + if addr := peer.Metadata["address"]; addr != "" { + return "https://" + addr + } + return "" +} diff --git a/internal/server/peers_handler.go b/internal/server/peers_handler.go index f637078..f9aa2dd 100644 --- a/internal/server/peers_handler.go +++ b/internal/server/peers_handler.go @@ -335,22 +335,6 @@ func proxyPeerCard(w http.ResponseWriter, r *http.Request, reg *a2a.Registry, pe _, _ = w.Write(body) } -// peerBaseURL derives a discovered peer's scheme+host base -// (http://host:port) from its advertised agent_card_url, falling back -// to the recorded address. Returns "" when neither is usable. -func peerBaseURL(peer *a2a.Peer) string { - if cardURL := peer.Metadata["agent_card_url"]; cardURL != "" { - // agent_card_url is "/.well-known/agent-card.json". - if i := strings.Index(cardURL, "/.well-known/"); i > 0 { - return cardURL[:i] - } - } - if addr := peer.Metadata["address"]; addr != "" { - return "http://" + addr - } - return "" -} - // proxyPeerAgents fetches a discovered peer's /v1/agents server-side and // relays it, so a dashboard can show which agents run on another device. // The local daemon authenticates to the remote with the shared circle @@ -364,16 +348,6 @@ func proxyPeerAgents(w http.ResponseWriter, r *http.Request, reg *a2a.Registry, writeJSON(w, http.StatusNotFound, map[string]any{"error": "no peer with that id", "got_id": peerID}) return } - key, _ := a2a.LoadCircleKey() - if key == "" { - // Not an error — just not set up yet. 200 so the dashboard's - // fetch resolves and it can render a "join a circle" hint. - writeJSON(w, http.StatusOK, map[string]any{ - "needs_circle_key": true, - "hint": "run `clawtool a2a circle-key generate` here, then `set` the same key on the peer", - }) - return - } base := peerBaseURL(peer) if base == "" { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "peer advertised no reachable address", "peer_id": peerID}) @@ -382,13 +356,18 @@ func proxyPeerAgents(w http.ResponseWriter, r *http.Request, reg *a2a.Registry, ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) defer cancel() + client, err := peerHTTPClient(peerExpectedFingerprint(peer), 4*time.Second) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "device identity unavailable: " + err.Error()}) + return + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/v1/agents", nil) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "bad peer address: " + err.Error()}) return } - req.Header.Set(a2a.CircleKeyHeader, key) - resp, err := http.DefaultClient.Do(req) + req.Header.Set(a2a.DeviceNameHeader, a2a.InstallDisplayName()) + resp, err := client.Do(req) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{ "error": "could not reach peer to fetch its agents: " + err.Error(), @@ -398,13 +377,12 @@ func proxyPeerAgents(w http.ResponseWriter, r *http.Request, reg *a2a.Registry, } defer resp.Body.Close() - if resp.StatusCode == http.StatusUnauthorized { - // The peer is reachable but rejected our circle key — different - // circle (or no key on its side). Surface it distinctly so the - // dashboard can say "peer is not in your circle". + if resp.StatusCode == http.StatusForbidden { + // The peer is reachable but hasn't approved this device yet. + // Surface it distinctly so the dashboard can say "pairing pending". writeJSON(w, http.StatusOK, map[string]any{ - "not_in_circle": true, - "hint": "this peer doesn't share your circle key — set the same key on both devices", + "pairing_required": true, + "hint": "approve this device on the peer to see its agents", }) return } @@ -437,44 +415,43 @@ func sendPairRequest(w http.ResponseWriter, r *http.Request, reg *a2a.Registry, writeJSON(w, http.StatusNotFound, map[string]any{"error": "no peer with that id", "got_id": peerID}) return } - key, _ := a2a.LoadCircleKey() - if key == "" { - writeJSON(w, http.StatusOK, map[string]any{ - "needs_circle_key": true, - "hint": "generate a pairing key here and set the same key on the peer first", - }) - return - } base := peerBaseURL(peer) if base == "" { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "peer advertised no reachable address", "peer_id": peerID}) return } reqBody, _ := json.Marshal(map[string]any{ - "from_fingerprint": a2a.InstallID(), + "from_fingerprint": a2a.DeviceID(), "from_display_name": a2a.InstallDisplayName(), "text": "wants to pair", }) ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second) defer cancel() + client, err := peerHTTPClient(peerExpectedFingerprint(peer), 4*time.Second) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]any{"error": "device identity unavailable: " + err.Error()}) + return + } req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/v1/relay", bytes.NewReader(reqBody)) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "bad peer address: " + err.Error()}) return } req.Header.Set("Content-Type", "application/json") - req.Header.Set(a2a.CircleKeyHeader, key) - resp, err := http.DefaultClient.Do(req) + req.Header.Set(a2a.DeviceNameHeader, a2a.InstallDisplayName()) + resp, err := client.Do(req) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": "could not reach peer: " + err.Error(), "peer_id": peerID}) return } defer resp.Body.Close() - if resp.StatusCode == http.StatusUnauthorized { - writeJSON(w, http.StatusOK, map[string]any{"not_in_circle": true, "hint": "this peer doesn't share your pairing key — set the same key on both"}) + // 403 pairing_required is the EXPECTED first-contact outcome: the mTLS + // handshake proved our identity, the peer recorded us as a pending + // request, and its operator must approve on screen. A 2xx means the + // peer already trusts us (re-pair / already approved). + if resp.StatusCode == http.StatusForbidden { + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "sent": true, "pending": true, "peer_id": peerID}) return } - // 202 pairing_required is the SUCCESS case: the peer recorded our request - // and is waiting for its operator to approve on screen. writeJSON(w, http.StatusOK, map[string]any{"ok": true, "sent": true, "peer_id": peerID, "peer_status": resp.StatusCode}) } From 0874b2da8cc4b2b458cd2f81da9a14e79a0a5a89 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 04:27:58 +0300 Subject: [PATCH 35/86] feat(daemon): wire the mTLS peer listener live (loopback main + LAN peer port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4b — makes Tier-2 trust active end to end. The managed daemon now keeps its main MCP listener on loopback+no-auth ALWAYS and, when LAN-exposed, starts the separate mTLS peer listener on its own free port via the new serve --peer-listen flag. mDNS advertises the https peer URL plus a device_id TXT record so peers learn the fingerprint to pin; the browse side carries device_id into peer metadata and resolves https for Tier-2 peers. The firewall/networking hooks target the peer port. The loopback surface is never bound beyond 127.0.0.1 — asserted by the rewritten daemon-args security test. --- cmd/clawtool/main.go | 14 ++++++++++ internal/a2a/mdns.go | 23 +++++++++++++--- internal/daemon/daemon.go | 48 ++++++++++++++++----------------- internal/daemon/lan_test.go | 54 ++++++++++++++++++++++--------------- internal/server/http.go | 19 ++++++++++--- 5 files changed, 105 insertions(+), 53 deletions(-) diff --git a/cmd/clawtool/main.go b/cmd/clawtool/main.go index b8fe015..07baab5 100755 --- a/cmd/clawtool/main.go +++ b/cmd/clawtool/main.go @@ -151,6 +151,12 @@ func parseServeFlags(argv []string) (server.HTTPOptions, bool, error) { } opts.Listen = argv[i+1] i++ + case "--peer-listen": + if i+1 >= len(argv) { + return opts, debug, fmt.Errorf("--peer-listen requires a value (e.g. '0.0.0.0:52829')") + } + opts.PeerListen = argv[i+1] + i++ case "--token-file": if i+1 >= len(argv) { return opts, debug, fmt.Errorf("--token-file requires a path") @@ -217,6 +223,14 @@ func parseServeFlags(argv []string) (server.HTTPOptions, bool, error) { "(this exposes clawtool's MCP surface to every device on your network)", opts.Listen) } + // The peer listener is LAN-facing by design (mTLS, fingerprint-gated), + // but binding beyond loopback still demands the explicit --allow-lan + // opt-in — same foot-gun guard as --listen. + if opts.PeerListen != "" && !opts.AllowLAN && !server.IsLoopbackAddress(opts.PeerListen) { + return opts, debug, fmt.Errorf( + "--peer-listen %s binds beyond loopback; pass --allow-lan to confirm", + opts.PeerListen) + } return opts, debug, nil } diff --git a/internal/a2a/mdns.go b/internal/a2a/mdns.go index 20b0351..e208c28 100644 --- a/internal/a2a/mdns.go +++ b/internal/a2a/mdns.go @@ -400,13 +400,20 @@ func (b *Browser) handleEntry(reg *Registry, entry *zeroconf.ServiceEntry) { } cardURL := txt["agent_card_url"] + // A Tier-2 peer (one advertising a device_id) serves its peer surface + // over https; older peers are http. Pick the scheme accordingly for + // the rebuilt URL below. + scheme := "http" + if txt["device_id"] != "" || strings.HasPrefix(cardURL, "https://") { + scheme = "https" + } // The announce side emits an `%h` hostname placeholder (an Avahi // convention grandcat/zeroconf does NOT substitute), so a TXT value - // like `http://%h:8080/...` is unusable as-is. Rebuild it from the + // like `https://%h:8080/...` is unusable as-is. Rebuild it from the // peer's resolved address whenever it's empty or still carries the // placeholder — that's the dialable URL a peer actually fetches. if (cardURL == "" || strings.Contains(cardURL, "%h")) && host != "" && entry.Port > 0 { - cardURL = fmt.Sprintf("http://%s:%d/.well-known/agent-card.json", host, entry.Port) + cardURL = fmt.Sprintf("%s://%s:%d/.well-known/agent-card.json", scheme, host, entry.Port) } // Register's identity tuple is (backend, path, session_id, @@ -427,6 +434,9 @@ func (b *Browser) handleEntry(reg *Registry, entry *zeroconf.ServiceEntry) { if v := txt["version"]; v != "" { metadata["peer_version"] = v } + if id := txt["device_id"]; id != "" { + metadata["device_id"] = id + } if host != "" { metadata["address"] = fmt.Sprintf("%s:%d", host, entry.Port) } @@ -467,12 +477,19 @@ func buildTXT(peerID string, card *Card, port int) []string { if v == "" { v = "unknown" } - return []string{ + txt := []string{ "peer_id=" + peerID, "agent_card_url=" + cardURL, "version=" + v, "protocol=" + CurrentProtocolVersion, } + // device_id is this install's certificate fingerprint (Tier-2). Peers + // pin it during the mTLS handshake; its presence also signals an + // https peer surface. Omitted only if cert generation failed. + if id := DeviceID(); id != "" { + txt = append(txt, "device_id="+id) + } + return txt } // parseTXT decodes the TXT record body back into a map. Tolerant diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 53c9c39..59222be 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -140,28 +140,27 @@ func SetLanExposure(on bool) error { // boundary and local consumers (codex / claude-code / gemini) reach // /mcp over 127.0.0.1 without a token. // -// LAN-exposed: bind all interfaces with --allow-lan + a token file. -// Security invariant — a non-loopback bind is NEVER --no-auth, so -// code-executing endpoints stay bearer-gated and only the read-only -// circle endpoints are reachable by peers (which present the circle -// key). The first --allow-lan run also installs the platform firewall -// rules via serve's auto-firewall hook. -func daemonServeArgs(port int, lanExposed bool) []string { - if lanExposed { - return []string{ - "serve", - "--listen", fmt.Sprintf("0.0.0.0:%d", port), - "--allow-lan", - "--token-file", TokenPath(), - "--mcp-http", - } - } - return []string{ +// The main listener ALWAYS stays loopback + no-auth — the local MCP +// surface (claude / codex over 127.0.0.1) is never exposed beyond the +// machine. LAN exposure (Tier-2) instead starts a SEPARATE mTLS peer +// listener on peerPort via --peer-listen: cross-device peers authenticate +// by certificate fingerprint over that surface, and code-executing +// endpoints stay off it entirely. The first --allow-lan run also installs +// the platform firewall rules via serve's auto-firewall hook. +func daemonServeArgs(port, peerPort int, lanExposed bool) []string { + args := []string{ "serve", "--listen", fmt.Sprintf("127.0.0.1:%d", port), "--no-auth", "--mcp-http", } + if lanExposed && peerPort > 0 { + args = append(args, + "--peer-listen", fmt.Sprintf("0.0.0.0:%d", peerPort), + "--allow-lan", + ) + } + return args } // configDir delegates to the central xdg package so every callsite @@ -422,17 +421,16 @@ func EnsureFrom(ctx context.Context, exePath string) (*State, error) { } lanExposed := LanExposureEnabled() + peerPort := 0 if lanExposed { - // A non-loopback bind enforces a bearer token, so make sure one - // exists before serve starts (idempotent — leaves an existing - // token untouched). - if _, statErr := os.Stat(TokenPath()); statErr != nil { - gen := exec.Command(self, "serve", "init-token", TokenPath()) - gen.Stdout, gen.Stderr = logFile, logFile - _ = gen.Run() + // The cross-device mTLS surface binds its own port, distinct from + // the loopback MCP port. Best-effort: if we can't grab one, the + // peer listener simply doesn't start (local gateway unaffected). + if pp, perr := pickFreePort(); perr == nil { + peerPort = pp } } - cmd := exec.Command(self, daemonServeArgs(port, lanExposed)...) + cmd := exec.Command(self, daemonServeArgs(port, peerPort, lanExposed)...) cmd.Stdout = logFile cmd.Stderr = logFile cmd.Stdin = nil diff --git a/internal/daemon/lan_test.go b/internal/daemon/lan_test.go index 073bb3f..7bf14a8 100644 --- a/internal/daemon/lan_test.go +++ b/internal/daemon/lan_test.go @@ -6,39 +6,51 @@ import ( "testing" ) -// The managed daemon's bind/auth posture is a security invariant: the -// loopback default is no-auth (machine is the trust boundary), and a -// LAN-exposed bind MUST carry a token and MUST NOT be --no-auth, so -// code-executing endpoints stay bearer-gated against the network. +// argValue returns the token immediately following flag, or "". +func argValue(args []string, flag string) string { + i := slices.Index(args, flag) + if i < 0 || i+1 >= len(args) { + return "" + } + return args[i+1] +} + +// The managed daemon's bind posture is a security invariant: the main MCP +// listener is ALWAYS loopback + no-auth (the machine is the trust boundary +// and the local surface is never exposed). LAN exposure (Tier-2) adds a +// SEPARATE mTLS peer listener on 0.0.0.0 — peers authenticate by cert +// fingerprint there, and the loopback socket is never bound to 0.0.0.0. func TestDaemonServeArgs_SecurityInvariants(t *testing.T) { - loop := daemonServeArgs(8765, false) - if !slices.Contains(loop, "127.0.0.1:8765") { + loop := daemonServeArgs(8765, 0, false) + if argValue(loop, "--listen") != "127.0.0.1:8765" { t.Errorf("loopback default must bind 127.0.0.1; got %v", loop) } if !slices.Contains(loop, "--no-auth") { t.Errorf("loopback default is no-auth; got %v", loop) } - if slices.Contains(loop, "--allow-lan") { - t.Errorf("loopback default must NOT pass --allow-lan; got %v", loop) + if slices.Contains(loop, "--allow-lan") || slices.Contains(loop, "--peer-listen") { + t.Errorf("loopback default must NOT expose a peer surface; got %v", loop) } - lan := daemonServeArgs(8765, true) - if !slices.Contains(lan, "0.0.0.0:8765") { - t.Errorf("LAN bind must listen on all interfaces; got %v", lan) + lan := daemonServeArgs(8765, 9000, true) + // SECURITY: the main listener stays loopback even when LAN-exposed — + // the no-auth MCP surface must never bind beyond 127.0.0.1. + if argValue(lan, "--listen") != "127.0.0.1:8765" { + t.Fatalf("SECURITY: main listener must stay loopback; got %v", lan) } - if !slices.Contains(lan, "--allow-lan") { - t.Errorf("LAN bind must pass --allow-lan; got %v", lan) + if !slices.Contains(lan, "--no-auth") { + t.Errorf("main listener stays no-auth on loopback; got %v", lan) } - if slices.Contains(lan, "--no-auth") { - t.Fatalf("SECURITY: a LAN bind must never be --no-auth; got %v", lan) + // The LAN surface is the separate mTLS peer listener on 0.0.0.0. + if argValue(lan, "--peer-listen") != "0.0.0.0:9000" { + t.Errorf("LAN exposure must add a peer listener on 0.0.0.0; got %v", lan) } - if !slices.Contains(lan, "--token-file") { - t.Fatalf("SECURITY: a LAN bind must enforce a token; got %v", lan) + if !slices.Contains(lan, "--allow-lan") { + t.Errorf("peer listener must pass --allow-lan; got %v", lan) } - // The token path must be the next arg after --token-file and look real. - i := slices.Index(lan, "--token-file") - if i+1 >= len(lan) || !strings.Contains(lan[i+1], "listener-token") { - t.Errorf("--token-file must point at the listener token; got %v", lan) + // Defence in depth: 0.0.0.0 must NEVER be the --listen target. + if strings.HasPrefix(argValue(lan, "--listen"), "0.0.0.0") { + t.Fatalf("SECURITY: loopback surface bound to 0.0.0.0; got %v", lan) } } diff --git a/internal/server/http.go b/internal/server/http.go index d947d26..5d13a3f 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -285,8 +285,11 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error { // backend is a no-op. Non-fatal on every failure path — // daemon boot proceeds in degraded (loopback-only mDNS) mode // if the user declines the UAC prompt. - if opts.AllowLAN && !opts.SkipFirewallSetup && !IsLoopbackAddress(opts.Listen) { - maybeSetupFirewall(os.Stderr, portFromListen(opts.Listen)) + // The LAN-facing surface is the peer mTLS listener now (the main + // listener stays loopback), so the firewall rule must open the peer + // port, not the loopback one. + if opts.AllowLAN && !opts.SkipFirewallSetup && opts.PeerListen != "" && !IsLoopbackAddress(opts.PeerListen) { + maybeSetupFirewall(os.Stderr, portFromListen(opts.PeerListen)) } // Auto-networking setup. Sibling of the firewall hook — @@ -294,7 +297,7 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error { // networkingMode=mirrored so LAN peer discovery actually // reaches the host's network. Default-on; suppressed by // --no-networking-setup. Non-fatal on every failure path. - if opts.AllowLAN && !opts.SkipNetworkingSetup && !IsLoopbackAddress(opts.Listen) { + if opts.AllowLAN && !opts.SkipNetworkingSetup && opts.PeerListen != "" && !IsLoopbackAddress(opts.PeerListen) { maybeSetupNetworking(os.Stderr, defaultPromptStdin()) } @@ -306,14 +309,22 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error { // down. We register the announcer's peer_id with the browser // so the daemon doesn't list its own announcement back into // the registry. + // Discovery advertises the surface peers actually dial. With a Tier-2 + // peer listener that's the mTLS port over https; otherwise the legacy + // http listener port. mdnsPort := portFromListen(opts.Listen) + mdnsScheme := "http" + if pp := portFromListen(opts.PeerListen); pp > 0 { + mdnsPort = pp + mdnsScheme = "https" + } var ( announcer *a2a.Announcer browser *a2a.Browser ) if mdnsPort > 0 { card := a2a.NewCard(a2a.CardOptions{ - URL: fmt.Sprintf("http://%%h:%d/.well-known/agent-card.json", mdnsPort), + URL: fmt.Sprintf("%s://%%h:%d/.well-known/agent-card.json", mdnsScheme, mdnsPort), }) ann, aerr := a2a.StartAnnounce(ctx, &card, mdnsPort) if aerr != nil { From fbf2bfb00e1aadc4f12518c8a309a76a48f68b56 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 04:33:50 +0300 Subject: [PATCH 36/86] feat(a2a): mutual pairing trust + matching confirmation code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of the Tier-2 rewrite. Pairing is now bidirectional and shows the same code on both screens: - PairingStore.Trust approves a fingerprint directly (no pending step) — the initiator's "Pair " click is their consent, so the target's cert is trusted up front. The target's operator still approves the initiator via the usual prompt (symmetric consent). - sendPairRequest trusts the target's pinned fingerprint and passes the pairing code from the peer's 403 back to the caller, so the initiating device displays the SAME code the receiver is showing — the operators compare them before approving. --- internal/a2a/pairing.go | 43 ++++++++++++++++++++++++++++++++ internal/a2a/pairing_test.go | 30 ++++++++++++++++++++++ internal/server/peers_handler.go | 27 +++++++++++++++++--- 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/internal/a2a/pairing.go b/internal/a2a/pairing.go index c189866..625bc1b 100644 --- a/internal/a2a/pairing.go +++ b/internal/a2a/pairing.go @@ -171,6 +171,49 @@ func (s *PairingStore) Observe(fingerprint, displayName, address string) (*Pairi return cloneReq(req), false, nil } +// Trust records a fingerprint as approved directly, without a pending +// step — used on the INITIATING side of a pair: clicking "Pair " +// is the operator's explicit consent to trust that device, so its +// certificate is approved up front. The receiving side still goes through +// its own approve prompt (the symmetric consent). Upserts: refreshes the +// advisory label/address on an existing record and flips it to approved. +func (s *PairingStore) Trust(fingerprint, displayName, address string) (*PairingRequest, error) { + if fingerprint == "" { + return nil, errors.New("empty fingerprint") + } + s.mu.Lock() + defer s.mu.Unlock() + + if r, ok := s.requests[fingerprint]; ok { + if displayName != "" { + r.DisplayName = displayName + } + if address != "" { + r.Address = address + } + r.State = PairingApproved + r.DecidedAt = time.Now().UTC() + if err := s.persist(); err != nil { + return nil, err + } + return cloneReq(r), nil + } + req := &PairingRequest{ + Fingerprint: fingerprint, + DisplayName: displayName, + Address: address, + Code: newPairingCode(), + State: PairingApproved, + FirstSeen: time.Now().UTC(), + DecidedAt: time.Now().UTC(), + } + s.requests[fingerprint] = req + if err := s.persist(); err != nil { + return nil, err + } + return cloneReq(req), nil +} + // IsApproved reports whether the fingerprint has been approved. Cheap // read for the inbound relay gate. func (s *PairingStore) IsApproved(fingerprint string) bool { diff --git a/internal/a2a/pairing_test.go b/internal/a2a/pairing_test.go index 0fd7b0d..06299a8 100644 --- a/internal/a2a/pairing_test.go +++ b/internal/a2a/pairing_test.go @@ -145,3 +145,33 @@ func TestList_NewestFirst(t *testing.T) { t.Errorf("List not newest-first: %+v", list) } } + +func TestTrust_DirectlyApprovesAndUpserts(t *testing.T) { + s := newTestStore(t) + + // First contact via Trust → approved straight away (initiator consent). + r, err := s.Trust("fp-peer", "macbook", "192.168.1.9:60111") + if err != nil { + t.Fatalf("Trust: %v", err) + } + if r.State != PairingApproved { + t.Errorf("Trust state = %q, want approved", r.State) + } + if !s.IsApproved("fp-peer") { + t.Error("fingerprint not approved after Trust") + } + + // Trust on an existing PENDING record flips it to approved (upsert). + if _, _, err := s.Observe("fp-pending", "win-pc", "192.168.1.20:60111"); err != nil { + t.Fatalf("Observe: %v", err) + } + if s.IsApproved("fp-pending") { + t.Fatal("pending peer should not be approved yet") + } + if _, err := s.Trust("fp-pending", "win-pc", ""); err != nil { + t.Fatalf("Trust upsert: %v", err) + } + if !s.IsApproved("fp-pending") { + t.Error("Trust did not flip pending → approved") + } +} diff --git a/internal/server/peers_handler.go b/internal/server/peers_handler.go index f9aa2dd..2c2cf8f 100644 --- a/internal/server/peers_handler.go +++ b/internal/server/peers_handler.go @@ -445,12 +445,33 @@ func sendPairRequest(w http.ResponseWriter, r *http.Request, reg *a2a.Registry, return } defer resp.Body.Close() + + // Mutual trust: clicking "Pair" is this operator's consent to trust the + // target, so we approve its fingerprint here. The target's own operator + // still approves us on their screen (the symmetric gesture). Trust the + // fingerprint the mTLS client pinned to — empty only for a pre-Tier-2 + // peer that advertised no device_id. + if fp := peerExpectedFingerprint(peer); fp != "" { + _, _ = a2a.GlobalPairingStore().Trust(fp, peer.DisplayName, peer.Metadata["address"]) + } + // 403 pairing_required is the EXPECTED first-contact outcome: the mTLS // handshake proved our identity, the peer recorded us as a pending - // request, and its operator must approve on screen. A 2xx means the - // peer already trusts us (re-pair / already approved). + // request, and its operator must approve on screen. Its response carries + // the pairing code — pass it back so this device shows the SAME code the + // peer is displaying, for the operators to compare. A 2xx means the peer + // already trusts us (re-pair / already approved). if resp.StatusCode == http.StatusForbidden { - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "sent": true, "pending": true, "peer_id": peerID}) + out := map[string]any{"ok": true, "sent": true, "pending": true, "peer_id": peerID} + var peerResp map[string]any + if b, rerr := io.ReadAll(io.LimitReader(resp.Body, 1<<16)); rerr == nil { + if json.Unmarshal(b, &peerResp) == nil { + if code, ok := peerResp["code"].(string); ok && code != "" { + out["code"] = code + } + } + } + writeJSON(w, http.StatusOK, out) return } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "sent": true, "peer_id": peerID, "peer_status": resp.StatusCode}) From 3d3605592adc8d94c0ec2f29c0319f6671f1bfea Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 04:36:37 +0300 Subject: [PATCH 37/86] feat(desktop): drop circle-key UI, surface the auto-pairing code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of the Tier-2 rewrite. The Network "This device" pane no longer has a pairing key to generate/copy/enter — pairing is automatic now. It keeps the Discoverable toggle and explains that pairing prompts with a short code to confirm. The Pair action shows the matching code returned by the daemon ("Pairing X — code 4821. Approve on that device.") so it lines up with the code the receiver's approve prompt already displays. Removed the now-dead circle-key state/helpers; dev stubs updated to exercise the code flow. --- desktop/apps/app-ui/src/dev-stubs.ts | 5 +- desktop/apps/app-ui/src/views/Network.tsx | 136 ++++------------------ 2 files changed, 23 insertions(+), 118 deletions(-) diff --git a/desktop/apps/app-ui/src/dev-stubs.ts b/desktop/apps/app-ui/src/dev-stubs.ts index f75cc01..ff97981 100644 --- a/desktop/apps/app-ui/src/dev-stubs.ts +++ b/desktop/apps/app-ui/src/dev-stubs.ts @@ -76,10 +76,11 @@ export function installDevStubs(platform: "windows" | "darwin" = "windows") { BridgeAdd: () => J({ ok: true }), LocalCard: () => J({ name: "clawtool@mac-studio", version: "1.0", url: "http://mac-studio.local:52828", skills: [{ id: "bash", name: "Bash" }, { id: "read", name: "Read" }, { id: "edit", name: "Edit" }, { id: "dispatch", name: "Agent dispatch" }] }), - PairList: () => J([]), + PairList: () => + J([{ fingerprint: "Z23WTLH3BQBYPUGRCUMYNC2SQFVPVOLGYXJK2LCHTZZ27UA2AQBA", display_name: "win-pc", address: "192.168.1.42:60111", code: "4821", state: "pending" }]), PairApprove: () => J({ ok: true }), PairDeny: () => J({ ok: true }), - PairRequest: () => J({ ok: true, sent: true }), + PairRequest: () => J({ ok: true, sent: true, pending: true, code: "4821" }), RunDoctor: () => J({ ok: true, output: "clawtool doctor — 0.22.171\n\n[binary] ✓ clawtool on PATH\n[daemon] ✓ running on 127.0.0.1:64205\n[agents] ✓ claude, codex callable\n[bridges] ⚠ gemini bridge missing\n\n1 warning, 0 errors" }), Quit: () => {}, }; diff --git a/desktop/apps/app-ui/src/views/Network.tsx b/desktop/apps/app-ui/src/views/Network.tsx index 8e71be4..38f2dca 100644 --- a/desktop/apps/app-ui/src/views/Network.tsx +++ b/desktop/apps/app-ui/src/views/Network.tsx @@ -1,15 +1,11 @@ import { useEffect, useState } from "react"; import { AgentIcon, Badge, Button, EmptyRow, List, SectionHeader, SidePane, Switch } from "@clawtool/design-system"; import type { BadgeTone } from "@clawtool/design-system"; -import { ChevronDown, ChevronRight, Lock } from "lucide-react"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { App, type Agent, type Brand, type Peer } from "@clawtool/bridge"; import { Toast } from "../components/Toast"; import styles from "../App.module.css"; -function maskKey(k: string): string { - return k && k.length > 14 ? `${k.slice(0, 8)}…${k.slice(-4)}` : k; -} - function statusChip(a: Agent): { label: string; tone: BadgeTone } { switch (a.status) { case "callable": @@ -32,25 +28,11 @@ function metaFor(a: Agent): string { type AgentState = "loading" | "none" | Agent[]; -async function copyText(text: string): Promise { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - return false; - } -} - export function Network({ brand, active }: { brand: Brand; active: boolean }) { const [banner, setBanner] = useState(""); const [peers, setPeers] = useState([]); - const [hasKey, setHasKey] = useState(false); - const [key, setKey] = useState(""); const [lan, setLan] = useState(false); const [lanBusy, setLanBusy] = useState(false); - const [joinOpen, setJoinOpen] = useState(false); - const [joinKey, setJoinKey] = useState(""); - const [joinErr, setJoinErr] = useState(""); const [toast, setToast] = useState(""); const [pairing, setPairing] = useState(""); const [filter, setFilter] = useState(""); @@ -68,56 +50,33 @@ export function Network({ brand, active }: { brand: Brand; active: boolean }) { setBanner(""); setPeers(snap.peers?.peers ?? []); } - async function loadCircle() { - const c = await App.circleStatus(); - setHasKey(c.ok === true && c.has_key === true); - setKey(c.key ?? ""); + async function loadLan() { const l = await App.lanStatus(); setLan(l.ok === true && l.enabled === true); } useEffect(() => { if (!active) return; - loadCircle(); + loadLan(); loadNetwork(); const t = setInterval(loadNetwork, 5000); return () => clearInterval(t); }, [active]); - async function generate() { - const r = await App.circleGenerate(); - await loadCircle(); - const k = typeof r.key === "string" ? r.key : ""; - if (r.ok && k) { - const ok = await copyText(k); - setToast(ok ? "Circle key generated and copied" : "Circle key generated"); - } - } - async function copyKey() { - setToast((await copyText(key)) ? "Copied to clipboard" : "Couldn't copy"); - } - async function join() { - setJoinErr(""); - const k = joinKey.trim(); - if (!k) return; - const r = await App.circleSet(k); - if (r.ok) { - setJoinOpen(false); - setJoinKey(""); - await loadCircle(); - setToast("Joined the circle"); - } else { - setJoinErr(typeof r.error === "string" ? r.error : "Couldn't set the circle key"); - } - } async function sendPair(p: Peer) { setPairing(p.peer_id); const r = (await App.pairRequest(p.peer_id)) as Record; - if (r.ok && r.sent) setToast(`Pairing request sent to ${p.display_name || p.peer_id}`); - else if (r.needs_circle_key) setToast("Generate a pairing key here first"); - else if (r.not_in_circle) setToast("That device isn't in your circle — share the same pairing key"); - else setToast(typeof r.error === "string" ? r.error : "Couldn't send the request"); + const name = p.display_name || p.peer_id; + if (r.ok && r.sent) { + const code = typeof r.code === "string" ? r.code : ""; + if (r.pending && code) setToast(`Pairing ${name} — code ${code}. Approve on that device to finish.`); + else if (r.pending) setToast(`Pairing request sent to ${name}. Approve on that device to finish.`); + else setToast(`Paired with ${name}.`); + } else { + setToast(typeof r.error === "string" ? r.error : "Couldn't reach that device"); + } setPairing(""); + loadNetwork(); } async function toggleExpand(p: Peer) { const next = new Set(expanded); @@ -138,7 +97,7 @@ export function Network({ brand, active }: { brand: Brand; active: boolean }) { async function toggleLan(next: boolean) { setLanBusy(true); await (next ? App.lanEnable() : App.lanDisable()); - await loadCircle(); + await loadLan(); setLanBusy(false); } @@ -244,72 +203,17 @@ export function Network({ brand, active }: { brand: Brand; active: boolean }) {
          Discoverable on your network
          - Paired devices on your network can see this machine and its agents. Code execution stays local-only; the - first time, your OS may ask to allow it through the firewall. + Lets other devices on your network find this machine and request to pair. Code execution stays + local-only; the first time, your OS may ask to allow it through the firewall.
          -
          - {lan ? null : ( -
          - - Turn on discoverability to set a pairing key -
          - )} -
          -
          -
          Pairing key
          - {hasKey ? "Active" : "Not set"} -
          -
          - Devices that share this key form one trusted network. Generate it on one device, then enter it on the - others. -
          - {hasKey ? ( -
          - {maskKey(key)} - - -
          - ) : joinOpen ? ( -
          - setJoinKey(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") join(); - if (e.key === "Escape") setJoinOpen(false); - }} - /> - {joinErr ?
          {joinErr}
          : null} -
          - - -
          -
          - ) : ( -
          - - -
          - )} +
          +
          + Pairing is automatic — when another device requests to pair, you'll see a prompt with a short code to + confirm. No keys to copy.
          From 76b37f264dea424735dca224299913b3a909df40 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 04:44:45 +0300 Subject: [PATCH 38/86] refactor: retire the circle key now that pairing is cert-based (Tier-2 Phase 7) The shared circle-key model is fully replaced by per-device certificate trust, so remove the dead surface: - internal/a2a/circlekey.go + its tests (deleted) - `clawtool a2a circle-key` subcommand - the circle-key branch in the peer auth gate; circleOrBearer renamed peerOrBearer (now purely fingerprint-or-bearer), comments updated - installer Circle{Status,Generate,Set,Clear} bindings + circleKeyRE - bridge circle* methods + CircleStatus type; Home's cross-device metric now reflects LAN discoverability; PeerAgentsResult uses pairing_required Obsolete circle-key middleware tests dropped; circle_test.go renamed peer_proxy_test.go. No shared secret remains anywhere in the trust path. --- cmd/clawtool-installer/app.go | 95 +-------------- desktop/apps/app-ui/src/dev-stubs.ts | 4 - desktop/apps/app-ui/src/views/Home.tsx | 4 +- desktop/packages/bridge/src/app.ts | 7 +- desktop/packages/bridge/src/types.ts | 4 +- internal/a2a/circlekey.go | 95 --------------- internal/a2a/circlekey_test.go | 111 ------------------ internal/cli/a2a.go | 76 ------------ internal/server/http.go | 61 ++++------ internal/server/peer_auth_test.go | 8 +- .../{circle_test.go => peer_proxy_test.go} | 82 ++----------- 11 files changed, 40 insertions(+), 507 deletions(-) delete mode 100644 internal/a2a/circlekey.go delete mode 100644 internal/a2a/circlekey_test.go rename internal/server/{circle_test.go => peer_proxy_test.go} (56%) diff --git a/cmd/clawtool-installer/app.go b/cmd/clawtool-installer/app.go index a5cd26c..45f63a9 100644 --- a/cmd/clawtool-installer/app.go +++ b/cmd/clawtool-installer/app.go @@ -12,7 +12,6 @@ import ( "os" "os/exec" "path/filepath" - "regexp" "runtime" "strings" "sync" @@ -529,10 +528,6 @@ func (a *App) EnsureGateway() string { return `{"ok":true}` } -// circleKeyRE matches a clawtool circle key (hex secret) so we can tell -// the key line apart from the CLI's "no circle key set …" notice. -var circleKeyRE = regexp.MustCompile(`^[0-9a-fA-F]{32,}$`) - // firstLine returns the first trimmed line of command output. func firstLine(b []byte) string { s := strings.TrimSpace(string(b)) @@ -542,96 +537,8 @@ func firstLine(b []byte) string { return s } -// CircleStatus reports whether this device has joined a cross-device -// "circle" — a shared key that lets paired devices list each other's -// agents over the LAN. Returns the key so the UI can show + copy it. -// Runs `clawtool a2a circle-key show` (the installer is a separate -// module and drives clawtool through its CLI, never importing internals). -func (a *App) CircleStatus() string { - bin, err := locateClawtool() - if err != nil { - return jsonErr(err.Error()) - } - cmd := exec.Command(bin, "a2a", "circle-key", "show") - hideConsole(cmd) - out, _ := cmd.CombinedOutput() // "no key" exits non-zero; that's fine - key := firstLine(out) - if !circleKeyRE.MatchString(key) { - key = "" - } - b, _ := json.Marshal(struct { - OK bool `json:"ok"` - HasKey bool `json:"has_key"` - Key string `json:"key,omitempty"` - }{OK: true, HasKey: key != "", Key: key}) - return string(b) -} - -// CircleGenerate creates a fresh circle key (saved on this device) and -// returns it so the UI can show it for copying to the other devices. -func (a *App) CircleGenerate() string { - bin, err := locateClawtool() - if err != nil { - return jsonErr(err.Error()) - } - cmd := exec.Command(bin, "a2a", "circle-key", "generate") - hideConsole(cmd) - out, err := cmd.Output() - if err != nil { - return jsonErr("could not generate a circle key") - } - key := firstLine(out) - if !circleKeyRE.MatchString(key) { - return jsonErr("unexpected circle-key output") - } - b, _ := json.Marshal(struct { - OK bool `json:"ok"` - Key string `json:"key"` - }{OK: true, Key: key}) - return string(b) -} - -// CircleSet joins an existing circle by saving the key generated on -// another device. The daemon reads the key per request, so it takes -// effect immediately — no restart. -func (a *App) CircleSet(key string) string { - key = strings.TrimSpace(key) - if key == "" { - return jsonErr("enter the circle key from your other device") - } - bin, err := locateClawtool() - if err != nil { - return jsonErr(err.Error()) - } - cmd := exec.Command(bin, "a2a", "circle-key", "set", key) - hideConsole(cmd) - if out, err := cmd.CombinedOutput(); err != nil { - msg := firstLine(out) - if msg == "" { - msg = "could not set the circle key" - } - return jsonErr(msg) - } - return `{"ok":true}` -} - -// CircleClear leaves the circle (clears the key), disabling cross-device -// agent enumeration on this device. -func (a *App) CircleClear() string { - bin, err := locateClawtool() - if err != nil { - return jsonErr(err.Error()) - } - cmd := exec.Command(bin, "a2a", "circle-key", "clear") - hideConsole(cmd) - if err := cmd.Run(); err != nil { - return jsonErr("could not leave the circle") - } - return `{"ok":true}` -} - // LanStatus reports whether the daemon is LAN-exposed (reachable by -// circle peers) or loopback-only. Reads `clawtool daemon lan status`. +// paired peers) or loopback-only. Reads `clawtool daemon lan status`. func (a *App) LanStatus() string { bin, err := locateClawtool() if err != nil { diff --git a/desktop/apps/app-ui/src/dev-stubs.ts b/desktop/apps/app-ui/src/dev-stubs.ts index ff97981..7bee893 100644 --- a/desktop/apps/app-ui/src/dev-stubs.ts +++ b/desktop/apps/app-ui/src/dev-stubs.ts @@ -42,10 +42,6 @@ export function installDevStubs(platform: "windows" | "darwin" = "windows") { EnsureGateway: () => J({ ok: true }), NetworkSnapshot: () => J({ ok: true, agents: { count: agents.length, agents }, peers: { count: peers.length, peers } }), PeerAgents: (...a: unknown[]) => J({ agents: peerAgents[String(a[0])] ?? [] }), - CircleStatus: () => J({ ok: true, has_key: true, key: "64f5612e49ce302b3d9f0ffcec383fa5760801d854434c39fec789c121bf5a77" }), - CircleGenerate: () => J({ ok: true, key: "ab".repeat(32) }), - CircleSet: () => J({ ok: true }), - CircleClear: () => J({ ok: true }), LanStatus: () => J({ ok: true, enabled: true }), LanEnable: () => J({ ok: true }), LanDisable: () => J({ ok: true }), diff --git a/desktop/apps/app-ui/src/views/Home.tsx b/desktop/apps/app-ui/src/views/Home.tsx index ff5041a..ee91290 100644 --- a/desktop/apps/app-ui/src/views/Home.tsx +++ b/desktop/apps/app-ui/src/views/Home.tsx @@ -19,8 +19,8 @@ export function Home({ brand, active, onNavigate }: { brand: Brand; active: bool } setAgents(snap.agents?.count ?? snap.agents?.agents?.length ?? 0); setPeers(snap.peers?.count ?? snap.peers?.peers?.length ?? 0); - const c = await App.circleStatus(); - setXd(c.ok === true && c.has_key === true); + const l = await App.lanStatus(); + setXd(l.ok === true && l.enabled === true); } useEffect(() => { diff --git a/desktop/packages/bridge/src/app.ts b/desktop/packages/bridge/src/app.ts index d163a05..340bbf4 100644 --- a/desktop/packages/bridge/src/app.ts +++ b/desktop/packages/bridge/src/app.ts @@ -6,7 +6,6 @@ import { callJSON, callRaw } from "./wails"; import type { AgentCard, Brand, - CircleStatus, LanStatus, Mode, NetworkSnapshot, @@ -61,11 +60,7 @@ export const App = { pairDeny: (selector: string) => callJSON("PairDeny", OK_FALSE, selector), pairRequest: (peerID: string) => callJSON("PairRequest", OK_FALSE, peerID), - // cross-device (circle key + LAN) - circleStatus: () => callJSON("CircleStatus", { ok: false }), - circleGenerate: () => callJSON("CircleGenerate", OK_FALSE), - circleSet: (key: string) => callJSON("CircleSet", OK_FALSE, key), - circleClear: () => callJSON("CircleClear", OK_FALSE), + // cross-device LAN exposure (discoverability) lanStatus: () => callJSON("LanStatus", { ok: false }), lanEnable: () => callJSON("LanEnable", OK_FALSE), lanDisable: () => callJSON("LanDisable", OK_FALSE), diff --git a/desktop/packages/bridge/src/types.ts b/desktop/packages/bridge/src/types.ts index e6e6b2c..05166d4 100644 --- a/desktop/packages/bridge/src/types.ts +++ b/desktop/packages/bridge/src/types.ts @@ -39,8 +39,7 @@ export type NetworkSnapshot = export type PeerAgentsResult = | { agents: Agent[] } - | { needs_circle_key: true } - | { not_in_circle: true } + | { pairing_required: true } | { ok: false; error?: string }; export type UpdateInfo = { @@ -76,7 +75,6 @@ export type PairingRequest = { }; export type Result = { ok: boolean; error?: string; [k: string]: unknown }; -export type CircleStatus = { ok: boolean; has_key?: boolean; key?: string }; export type LanStatus = { ok: boolean; enabled?: boolean }; // Wails runtime events emitted by the Go side. diff --git a/internal/a2a/circlekey.go b/internal/a2a/circlekey.go deleted file mode 100644 index e0b652f..0000000 --- a/internal/a2a/circlekey.go +++ /dev/null @@ -1,95 +0,0 @@ -// Package a2a — circle key (ADR-024 cross-device trust). -// -// A "circle" is the set of a user's own machines that should see each -// other's agents. The circle key is a pre-shared secret the user sets -// once on every device they own (think Tailscale auth key / Syncthing -// shared folder). Possessing it authorizes the READ-ONLY cross-device -// peer endpoints — listing a remote peer's agents — and nothing else: -// it never unlocks /v1/send_message or /mcp, which execute code and -// stay strictly bearer-token gated. -// -// Trust model rationale: mDNS discovery is LAN-public (any device can -// see that a clawtool exists and read its capability card), but the -// agent inventory is not broadcast — a peer must prove circle -// membership to enumerate it. No key configured ⇒ cross-device agent -// enumeration is simply disabled, so nothing leaks by default. -package a2a - -import ( - "crypto/rand" - "crypto/subtle" - "encoding/hex" - "errors" - "os" - "path/filepath" - "strings" - - "github.com/cogitave/clawtool/internal/atomicfile" - "github.com/cogitave/clawtool/internal/xdg" -) - -// CircleKeyHeader is the HTTP header a peer presents to prove circle -// membership when fetching another peer's read-only endpoints. -const CircleKeyHeader = "X-Clawtool-Circle" - -// CircleKeyPath is where the shared secret lives (0600). Same XDG -// conventions as the daemon's token + state files. -func CircleKeyPath() string { - return filepath.Join(xdg.ConfigDir(), "circle-key") -} - -// LoadCircleKey returns the configured circle key (whitespace-trimmed), -// or "" with nil error when none is set — callers treat empty as -// "cross-device enumeration disabled". -func LoadCircleKey() (string, error) { - b, err := os.ReadFile(CircleKeyPath()) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return "", nil - } - return "", err - } - return strings.TrimSpace(string(b)), nil -} - -// SaveCircleKey persists key atomically at 0600. A blank key is -// rejected — use ClearCircleKey to remove membership. -func SaveCircleKey(key string) error { - key = strings.TrimSpace(key) - if key == "" { - return errors.New("circle key must not be empty (use clear to remove)") - } - return atomicfile.WriteFileMkdir(CircleKeyPath(), []byte(key+"\n"), 0o600, 0o700) -} - -// ClearCircleKey removes the key file. Missing file is not an error. -func ClearCircleKey() error { - if err := os.Remove(CircleKeyPath()); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil -} - -// GenerateCircleKey returns a fresh 32-byte hex secret. It does NOT -// persist it — the caller decides whether to save locally and/or print -// it for the operator to copy onto their other devices. -func GenerateCircleKey() (string, error) { - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return hex.EncodeToString(buf), nil -} - -// CircleKeyMatches reports whether presented equals expected in -// constant time. Returns false when expected is empty (no circle -// configured ⇒ deny) so a missing key can never be matched by an empty -// header. -func CircleKeyMatches(expected, presented string) bool { - expected = strings.TrimSpace(expected) - presented = strings.TrimSpace(presented) - if expected == "" || presented == "" { - return false - } - return subtle.ConstantTimeCompare([]byte(expected), []byte(presented)) == 1 -} diff --git a/internal/a2a/circlekey_test.go b/internal/a2a/circlekey_test.go deleted file mode 100644 index 985e832..0000000 --- a/internal/a2a/circlekey_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package a2a - -import ( - "os" - "path/filepath" - "testing" -) - -// withTempConfig points XDG_CONFIG_HOME at a temp dir so circle-key -// reads/writes don't touch the real config. -func withTempConfig(t *testing.T) { - t.Helper() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) -} - -func TestCircleKey_SaveLoadRoundTrip(t *testing.T) { - withTempConfig(t) - - if got, err := LoadCircleKey(); err != nil || got != "" { - t.Fatalf("fresh load = (%q, %v), want empty + nil", got, err) - } - if err := SaveCircleKey(" secret-123 "); err != nil { - t.Fatalf("save: %v", err) - } - got, err := LoadCircleKey() - if err != nil { - t.Fatalf("load: %v", err) - } - if got != "secret-123" { - t.Errorf("loaded key = %q, want trimmed secret-123", got) - } - // File must be 0600 — it's a shared secret. - info, err := os.Stat(CircleKeyPath()) - if err != nil { - t.Fatalf("stat: %v", err) - } - if perm := info.Mode().Perm(); perm != 0o600 { - t.Errorf("circle-key mode = %o, want 600", perm) - } -} - -func TestCircleKey_RejectsEmptySave(t *testing.T) { - withTempConfig(t) - if err := SaveCircleKey(" "); err == nil { - t.Error("saving a blank key should error") - } -} - -func TestCircleKey_Clear(t *testing.T) { - withTempConfig(t) - if err := SaveCircleKey("k"); err != nil { - t.Fatal(err) - } - if err := ClearCircleKey(); err != nil { - t.Fatalf("clear: %v", err) - } - if got, _ := LoadCircleKey(); got != "" { - t.Errorf("after clear, load = %q, want empty", got) - } - // Clearing again (missing file) is not an error. - if err := ClearCircleKey(); err != nil { - t.Errorf("clear on missing file should be a no-op, got %v", err) - } -} - -func TestGenerateCircleKey_UniqueHex(t *testing.T) { - a, err := GenerateCircleKey() - if err != nil { - t.Fatal(err) - } - b, err := GenerateCircleKey() - if err != nil { - t.Fatal(err) - } - if a == b { - t.Error("two generated keys collided") - } - if len(a) != 64 { // 32 bytes hex-encoded - t.Errorf("key length = %d, want 64 hex chars", len(a)) - } -} - -func TestCircleKeyMatches(t *testing.T) { - cases := []struct { - name string - expected, present string - want bool - }{ - {"equal", "abc", "abc", true}, - {"equal with whitespace", "abc", " abc \n", true}, - {"mismatch", "abc", "xyz", false}, - {"empty expected denies", "", "abc", false}, - {"empty presented denies", "abc", "", false}, - {"both empty denies", "", "", false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := CircleKeyMatches(c.expected, c.present); got != c.want { - t.Errorf("CircleKeyMatches(%q,%q) = %v, want %v", c.expected, c.present, got, c.want) - } - }) - } -} - -// Guard the path uses the clawtool config dir, not the bare home. -func TestCircleKeyPath_UnderConfigDir(t *testing.T) { - withTempConfig(t) - if base := filepath.Base(CircleKeyPath()); base != "circle-key" { - t.Errorf("path base = %q, want circle-key", base) - } -} diff --git a/internal/cli/a2a.go b/internal/cli/a2a.go index 8108a02..2c3d62d 100644 --- a/internal/cli/a2a.go +++ b/internal/cli/a2a.go @@ -31,14 +31,6 @@ const a2aUsage = `Usage: runtime family; circle = group name. --format = table|tsv|json (default table). - clawtool a2a circle-key [show|generate|set |clear] - Manage the shared cross-device - "circle" key. Devices sharing the key - can list each other's agents over the - LAN; without it, cross-device agent - enumeration is disabled. generate on - one device, set the same key on the - others. A2A is the Agent2Agent protocol (Linux Foundation / Google). The card describes what this agent does (capabilities + skills + auth) — NOT @@ -61,8 +53,6 @@ func (a *App) runA2A(argv []string) int { return a.runA2ACard(argv[1:]) case "peers": return a.runA2APeers(argv[1:]) - case "circle-key", "circle": - return a.runA2ACircleKey(argv[1:]) default: fmt.Fprintf(a.Stderr, "clawtool a2a: unknown subcommand %q\n\n%s", argv[0], a2aUsage) @@ -70,72 +60,6 @@ func (a *App) runA2A(argv []string) int { } } -// runA2ACircleKey manages the shared cross-device circle key. Devices -// that share the key can list each other's agents over the LAN; without -// it, agent enumeration across devices is disabled (see internal/a2a -// circlekey.go). No daemon restart is needed — the server reads the key -// per request. -func (a *App) runA2ACircleKey(argv []string) int { - sub := "show" - if len(argv) > 0 { - sub = argv[0] - } - switch sub { - case "show": - key, err := a2a.LoadCircleKey() - if err != nil { - fmt.Fprintf(a.Stderr, "clawtool a2a circle-key: %v\n", err) - return 1 - } - if key == "" { - fmt.Fprintln(a.Stdout, "no circle key set — run `clawtool a2a circle-key generate` on one device, then `set ` on the others") - return 0 - } - fmt.Fprintln(a.Stdout, key) - return 0 - - case "generate": - key, err := a2a.GenerateCircleKey() - if err != nil { - fmt.Fprintf(a.Stderr, "clawtool a2a circle-key: generate: %v\n", err) - return 1 - } - if err := a2a.SaveCircleKey(key); err != nil { - fmt.Fprintf(a.Stderr, "clawtool a2a circle-key: save: %v\n", err) - return 1 - } - fmt.Fprintln(a.Stdout, key) - fmt.Fprintln(a.Stderr, "✓ circle key generated and saved on this device") - fmt.Fprintln(a.Stderr, " run this on your other devices to join the circle:") - fmt.Fprintf(a.Stderr, " clawtool a2a circle-key set %s\n", key) - return 0 - - case "set": - if len(argv) < 2 || strings.TrimSpace(argv[1]) == "" { - fmt.Fprintln(a.Stderr, "usage: clawtool a2a circle-key set ") - return 2 - } - if err := a2a.SaveCircleKey(argv[1]); err != nil { - fmt.Fprintf(a.Stderr, "clawtool a2a circle-key: set: %v\n", err) - return 1 - } - fmt.Fprintln(a.Stderr, "✓ joined the circle — this device can now see (and be seen by) peers sharing this key") - return 0 - - case "clear": - if err := a2a.ClearCircleKey(); err != nil { - fmt.Fprintf(a.Stderr, "clawtool a2a circle-key: clear: %v\n", err) - return 1 - } - fmt.Fprintln(a.Stderr, "✓ left the circle — cross-device agent enumeration disabled on this device") - return 0 - - default: - fmt.Fprintf(a.Stderr, "clawtool a2a circle-key: unknown subcommand %q (show|generate|set|clear)\n", sub) - return 2 - } -} - func (a *App) runA2ACard(argv []string) int { var nameOverride string for i := 0; i < len(argv); i++ { diff --git a/internal/server/http.go b/internal/server/http.go index 5d13a3f..57c0509 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -162,12 +162,12 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error { authed := authMiddleware(token, trustLoopback) mux.Handle("/v1/health", authed(http.HandlerFunc(handleHealth))) - // /v1/agents is read-only and circle-aware: a peer device in the same - // circle (valid X-Clawtool-Circle key) may enumerate this node's - // agents even without the bearer token. That's what lets a dashboard - // on one machine list the agents running on another. Code-executing - // endpoints below stay bearer-only. - mux.Handle("/v1/agents", circleOrBearer(token, trustLoopback)(http.HandlerFunc(handleAgents))) + // /v1/agents is read-only and peer-aware: a paired device (approved + // mTLS cert fingerprint) may enumerate this node's agents even without + // the bearer token. That's what lets a dashboard on one machine list + // the agents running on another. Code-executing endpoints below stay + // bearer-only. + mux.Handle("/v1/agents", peerOrBearer(token, trustLoopback)(http.HandlerFunc(handleAgents))) mux.Handle("/v1/send_message", authed(http.HandlerFunc(handleSendMessage))) mux.Handle("/v1/recipes", authed(http.HandlerFunc(handleRecipes))) mux.Handle("/v1/recipe/apply", authed(http.HandlerFunc(handleRecipeApply))) @@ -177,13 +177,11 @@ func ServeHTTP(ctx context.Context, opts HTTPOptions) error { // Single mux entry routes all subpaths via the trailing slash. mux.Handle("/v1/peers", authed(http.HandlerFunc(handlePeers))) mux.Handle("/v1/peers/", authed(http.HandlerFunc(handlePeers))) - // /v1/relay — cross-device message ingress. Circle-aware like - // /v1/agents: a paired peer on the same circle POSTs a message - // here over the LAN. The handler enforces the operator's - // first-contact pairing gate before any delivery (it enqueues - // into local agents' inboxes, not code execution — so circle - // auth + the pairing approval are the trust boundary). - mux.Handle("/v1/relay", circleOrBearer(token, trustLoopback)(http.HandlerFunc(handleRelay))) + // /v1/relay — cross-device message ingress. Peer-aware like + // /v1/agents: a paired device POSTs a message here over the LAN + // (mTLS). Delivery enqueues into local agents' inboxes, not code + // execution — the approved-fingerprint gate is the trust boundary. + mux.Handle("/v1/relay", peerOrBearer(token, trustLoopback)(http.HandlerFunc(handleRelay))) // /v1/biam/subscribe — SSE A2A async-push (ADR-024 Phase 4). // task-scoped, with Last-Event-ID replay against the per-task // ring buffer in internal/agents/biam.Events. @@ -478,18 +476,17 @@ func authMiddleware(expected string, trustLoopback bool) func(http.Handler) http } } -// circleOrBearer authorizes READ-ONLY cross-device endpoints. A request -// passes if it carries either a valid bearer token (the local daemon's -// own token, same as authMiddleware) OR a valid circle-key header — the -// shared secret that proves the caller is one of the user's own devices -// (see internal/a2a circlekey.go). The circle key is read per request -// from disk so rotating it via `clawtool a2a circle-key` takes effect -// without a daemon restart. +// peerOrBearer authorizes the cross-device endpoints (/v1/agents read, +// /v1/relay). A request passes if it's a trusted remote device (its mTLS +// client-cert fingerprint is approved in the PairingStore) OR carries the +// local daemon's bearer token. An unknown peer fingerprint isn't a flat +// reject — it's recorded as a pending request (surfacing the approve/deny +// prompt) and answered with pairing_required. // // NEVER wrap a code-executing endpoint (send_message, mcp) with this — -// the circle key is deliberately scoped to read-only agent/peer -// enumeration. Those stay bearer-only. -func circleOrBearer(token string, trustLoopback bool) func(http.Handler) http.Handler { +// the peer surface is deliberately scoped to read-only agent/peer +// enumeration + relay. Those stay bearer-only. +func peerOrBearer(token string, trustLoopback bool) func(http.Handler) http.Handler { exp := []byte(token) return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -497,12 +494,8 @@ func circleOrBearer(token string, trustLoopback bool) func(http.Handler) http.Ha // client certificate is a remote device. It is authorized ONLY // if its certificate fingerprint has been approved in the // PairingStore — checked FIRST so this gate holds even when the - // loopback daemon runs in no-auth mode. An unknown fingerprint - // isn't a flat reject: we record it as a pending request (which - // surfaces the approve/deny prompt on this machine) and answer - // with pairing_required so the caller knows to wait for approval. - // The shared circle key plays no part here — possession of the - // private key behind an approved fingerprint is the credential. + // loopback daemon runs in no-auth mode. Possession of the private + // key behind an approved fingerprint is the credential. if r.TLS != nil { if len(r.TLS.PeerCertificates) == 0 { writeJSON(w, http.StatusForbidden, map[string]any{ @@ -539,7 +532,6 @@ func circleOrBearer(token string, trustLoopback bool) func(http.Handler) http.Ha next.ServeHTTP(w, r) return } - // 1. Bearer token. h := r.Header.Get("Authorization") const prefix = "Bearer " if strings.HasPrefix(h, prefix) { @@ -549,15 +541,8 @@ func circleOrBearer(token string, trustLoopback bool) func(http.Handler) http.Ha return } } - // 2. Circle key (a peer device proving same-circle membership). - if presented := r.Header.Get(a2a.CircleKeyHeader); presented != "" { - if key, _ := a2a.LoadCircleKey(); a2a.CircleKeyMatches(key, presented) { - next.ServeHTTP(w, r) - return - } - } writeJSON(w, http.StatusUnauthorized, map[string]any{ - "error": "invalid token, and no matching " + a2a.CircleKeyHeader + " circle key", + "error": "invalid token, and not a paired device", }) }) } diff --git a/internal/server/peer_auth_test.go b/internal/server/peer_auth_test.go index d44654a..35aea5b 100644 --- a/internal/server/peer_auth_test.go +++ b/internal/server/peer_auth_test.go @@ -25,7 +25,7 @@ func peerRequest(t *testing.T, cert tls.Certificate) *http.Request { return req } -func TestCircleOrBearer_PeerFingerprintTrust(t *testing.T) { +func TestPeerOrBearer_PeerFingerprintTrust(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) a2a.ResetDeviceCertCacheForTest() cert, err := a2a.DeviceCertificate() @@ -43,7 +43,7 @@ func TestCircleOrBearer_PeerFingerprintTrust(t *testing.T) { t.Cleanup(func() { a2a.SetGlobalPairingStore(nil) }) reached := false - h := circleOrBearer("local-token", true)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + h := peerOrBearer("local-token", true)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { reached = true w.WriteHeader(http.StatusOK) })) @@ -90,8 +90,8 @@ func TestCircleOrBearer_PeerFingerprintTrust(t *testing.T) { } } -func TestCircleOrBearer_PeerWithoutClientCertRejected(t *testing.T) { - h := circleOrBearer("local-token", true)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { +func TestPeerOrBearer_PeerWithoutClientCertRejected(t *testing.T) { + h := peerOrBearer("local-token", true)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) req := httptest.NewRequest(http.MethodPost, "/v1/relay", nil) diff --git a/internal/server/circle_test.go b/internal/server/peer_proxy_test.go similarity index 56% rename from internal/server/circle_test.go rename to internal/server/peer_proxy_test.go index f87ed0b..24940ca 100644 --- a/internal/server/circle_test.go +++ b/internal/server/peer_proxy_test.go @@ -11,19 +11,19 @@ import ( "github.com/cogitave/clawtool/internal/a2a" ) -// circleMux mounts a single circle-aware /v1/agents endpoint with the +// peerMux mounts a single peer-aware /v1/agents endpoint with the // given bearer token, mirroring the production wiring. -func circleMux(token string) *http.ServeMux { +func peerMux(token string) *http.ServeMux { mux := http.NewServeMux() - mux.Handle("/v1/agents", circleOrBearer(token, false)(http.HandlerFunc(handleAgents))) + mux.Handle("/v1/agents", peerOrBearer(token, false)(http.HandlerFunc(handleAgents))) return mux } -// TestCircleOrBearer_BearerStillWorks: a valid bearer token passes, as -// before. -func TestCircleOrBearer_BearerStillWorks(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // no circle key configured - srv := httptest.NewServer(circleMux("real-token")) +// TestPeerOrBearer_BearerStillWorks: a valid bearer token passes (the +// local-daemon credential path), independent of the mTLS peer path. +func TestPeerOrBearer_BearerStillWorks(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + srv := httptest.NewServer(peerMux("real-token")) defer srv.Close() req, _ := http.NewRequest("GET", srv.URL+"/v1/agents", nil) @@ -38,72 +38,6 @@ func TestCircleOrBearer_BearerStillWorks(t *testing.T) { } } -// TestCircleOrBearer_CircleKeyPasses: with a circle key configured, a -// caller presenting the matching X-Clawtool-Circle header passes even -// without the bearer token. This is the cross-device path. -func TestCircleOrBearer_CircleKeyPasses(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - if err := a2a.SaveCircleKey("circle-secret"); err != nil { - t.Fatal(err) - } - srv := httptest.NewServer(circleMux("real-token")) - defer srv.Close() - - req, _ := http.NewRequest("GET", srv.URL+"/v1/agents", nil) - req.Header.Set(a2a.CircleKeyHeader, "circle-secret") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Errorf("matching circle key = %d, want 200", resp.StatusCode) - } -} - -// TestCircleOrBearer_WrongCircleKeyRejected: a wrong circle key and no -// bearer is 401 — and a configured-but-unmatched key never leaks. -func TestCircleOrBearer_WrongCircleKeyRejected(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - if err := a2a.SaveCircleKey("circle-secret"); err != nil { - t.Fatal(err) - } - srv := httptest.NewServer(circleMux("real-token")) - defer srv.Close() - - req, _ := http.NewRequest("GET", srv.URL+"/v1/agents", nil) - req.Header.Set(a2a.CircleKeyHeader, "wrong-secret") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Errorf("wrong circle key = %d, want 401", resp.StatusCode) - } -} - -// TestCircleOrBearer_NoKeyConfiguredDeniesHeaderOnly: if no circle key -// is set on this node, presenting any circle header must NOT pass (only -// the bearer should). Guards against an empty key matching an empty or -// arbitrary header. -func TestCircleOrBearer_NoKeyConfiguredDeniesHeaderOnly(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // no key - srv := httptest.NewServer(circleMux("real-token")) - defer srv.Close() - - req, _ := http.NewRequest("GET", srv.URL+"/v1/agents", nil) - req.Header.Set(a2a.CircleKeyHeader, "anything") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Errorf("circle header with no key configured = %d, want 401", resp.StatusCode) - } -} - // TestProxyPeerAgents_RelaysOverMTLS: the proxy reaches the peer over mTLS // (presenting this device's cert + the device-name header) and relays the // peer's agents. No shared secret involved. From 8855054b62ed2dd0d79e7b07a7c2e1b933191339 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 04:56:03 +0300 Subject: [PATCH 39/86] feat(desktop): pairing progress + paired state on the initiator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initiator only flashed a toast and never learned the outcome. Now clicking Pair opens a progress modal: it shows the confirmation code to compare, waits with a spinner while polling the target's agents, and resolves to "Paired ✓" the moment the other device approves (the target's /v1/agents stops returning pairing_required) — or "Couldn't pair" after a timeout. Device rows whose fingerprint is approved now show a "Paired" badge instead of the Pair button (matched via the peer's device_id against the pairing ledger). Added device_id to the Peer metadata type; dev stubs exercise both the waiting→paired flow and the paired badge. --- .../src/components/PairProgress.module.css | 59 +++++++++++++++ .../app-ui/src/components/PairProgress.tsx | 71 +++++++++++++++++++ desktop/apps/app-ui/src/dev-stubs.ts | 6 +- desktop/apps/app-ui/src/views/Network.tsx | 62 +++++++++++++--- desktop/packages/bridge/src/types.ts | 2 +- 5 files changed, 186 insertions(+), 14 deletions(-) create mode 100644 desktop/apps/app-ui/src/components/PairProgress.module.css create mode 100644 desktop/apps/app-ui/src/components/PairProgress.tsx diff --git a/desktop/apps/app-ui/src/components/PairProgress.module.css b/desktop/apps/app-ui/src/components/PairProgress.module.css new file mode 100644 index 0000000..50b0c89 --- /dev/null +++ b/desktop/apps/app-ui/src/components/PairProgress.module.css @@ -0,0 +1,59 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: grid; + place-items: center; + z-index: 100; +} +.modal { + width: 380px; + max-width: calc(100vw - 48px); + background: var(--surface); + border: 1px solid var(--hairline-strong); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-overlay); + padding: 22px; + text-align: center; +} +.title { font-size: var(--size-lg); font-weight: 600; } +.body { color: var(--text-secondary); font-size: 13px; line-height: 1.5; margin-top: 8px; } +.body b { color: var(--text); font-weight: 600; } +.codeBig { + margin-top: 16px; + font: 700 30px/1 var(--font-mono); + letter-spacing: 0.18em; + color: var(--accent); +} +.wait { + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + margin-top: 16px; + color: var(--text-secondary); + font-size: 12.5px; +} +.spinner { + width: 14px; + height: 14px; + border: 2px solid var(--hairline-strong); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.7s linear infinite; +} +@keyframes spin { + to { transform: rotate(360deg); } +} +.big { + margin: 6px auto 0; + width: 44px; + height: 44px; + display: grid; + place-items: center; + border-radius: 50%; + font-size: 22px; +} +.ok { background: color-mix(in srgb, var(--success) 18%, transparent); color: var(--success); } +.bad { background: color-mix(in srgb, var(--warning) 18%, transparent); color: var(--warning); } +.actions { display: flex; justify-content: center; gap: 12px; margin-top: 20px; } diff --git a/desktop/apps/app-ui/src/components/PairProgress.tsx b/desktop/apps/app-ui/src/components/PairProgress.tsx new file mode 100644 index 0000000..cf0a9b5 --- /dev/null +++ b/desktop/apps/app-ui/src/components/PairProgress.tsx @@ -0,0 +1,71 @@ +import { Button } from "@clawtool/design-system"; +import styles from "./PairProgress.module.css"; + +export type PairPhase = "waiting" | "paired" | "failed"; + +// The initiating side of a pair: after clicking Pair, this shows the +// confirmation code to compare, waits for the other device to approve +// (polled by the caller), then resolves to paired or failed. +export function PairProgress({ + name, + code, + phase, + onClose, +}: { + name: string; + code: string; + phase: PairPhase; + onClose: () => void; +}) { + return ( +
          +
          + {phase === "waiting" ? ( + <> +
          Pairing with {name}
          +

          + Make sure this code matches the one shown on {name}, then approve there. +

          + {code ?
          {code}
          : null} +
          + Waiting for {name} to approve… +
          +
          + +
          + + ) : phase === "paired" ? ( + <> +
          +
          + Paired with {name} +
          +

          This device and {name} can now use each other's agents.

          +
          + +
          + + ) : ( + <> +
          !
          +
          + Couldn't pair +
          +

          + {name} didn't approve in time, or wasn't reachable. Make sure it's discoverable and try again. +

          +
          + +
          + + )} +
          +
          + ); +} diff --git a/desktop/apps/app-ui/src/dev-stubs.ts b/desktop/apps/app-ui/src/dev-stubs.ts index 7bee893..d037641 100644 --- a/desktop/apps/app-ui/src/dev-stubs.ts +++ b/desktop/apps/app-ui/src/dev-stubs.ts @@ -14,8 +14,8 @@ const agents = [ { instance: "opencode", family: "opencode", status: "binary-missing", callable: false }, ]; const peers = [ - { peer_id: "p1", display_name: "win-pc", status: "online", metadata: { hostname: "win-pc", address: "192.168.1.42:52828" } }, - { peer_id: "p2", display_name: "macbook-air", status: "offline", metadata: { hostname: "macbook-air", address: "192.168.1.51:52828" } }, + { peer_id: "p1", display_name: "win-pc", status: "online", metadata: { hostname: "win-pc", address: "192.168.1.42:60111", device_id: "WB4SWDH3S5WD5TY3UGTO3YBNOFBH67TDUT44736CSCDYZJUQKF6Q" } }, + { peer_id: "p2", display_name: "macbook-air", status: "offline", metadata: { hostname: "macbook-air", address: "192.168.1.51:60111", device_id: "Z23WTLH3BQBYPUGRCUMYNC2SQFVPVOLGYXJK2LCHTZZ27UA2AQBA" } }, ]; const peerAgents: Record>> = { @@ -73,7 +73,7 @@ export function installDevStubs(platform: "windows" | "darwin" = "windows") { LocalCard: () => J({ name: "clawtool@mac-studio", version: "1.0", url: "http://mac-studio.local:52828", skills: [{ id: "bash", name: "Bash" }, { id: "read", name: "Read" }, { id: "edit", name: "Edit" }, { id: "dispatch", name: "Agent dispatch" }] }), PairList: () => - J([{ fingerprint: "Z23WTLH3BQBYPUGRCUMYNC2SQFVPVOLGYXJK2LCHTZZ27UA2AQBA", display_name: "win-pc", address: "192.168.1.42:60111", code: "4821", state: "pending" }]), + J([{ fingerprint: "Z23WTLH3BQBYPUGRCUMYNC2SQFVPVOLGYXJK2LCHTZZ27UA2AQBA", display_name: "macbook-air", address: "192.168.1.51:60111", code: "", state: "approved" }]), PairApprove: () => J({ ok: true }), PairDeny: () => J({ ok: true }), PairRequest: () => J({ ok: true, sent: true, pending: true, code: "4821" }), diff --git a/desktop/apps/app-ui/src/views/Network.tsx b/desktop/apps/app-ui/src/views/Network.tsx index 38f2dca..5674266 100644 --- a/desktop/apps/app-ui/src/views/Network.tsx +++ b/desktop/apps/app-ui/src/views/Network.tsx @@ -4,8 +4,11 @@ import type { BadgeTone } from "@clawtool/design-system"; import { ChevronDown, ChevronRight } from "lucide-react"; import { App, type Agent, type Brand, type Peer } from "@clawtool/bridge"; import { Toast } from "../components/Toast"; +import { PairProgress, type PairPhase } from "../components/PairProgress"; import styles from "../App.module.css"; +type Progress = { peerId: string; name: string; code: string; phase: PairPhase }; + function statusChip(a: Agent): { label: string; tone: BadgeTone } { switch (a.status) { case "callable": @@ -39,6 +42,8 @@ export function Network({ brand, active }: { brand: Brand; active: boolean }) { const [deviceOpen, setDeviceOpen] = useState(false); const [expanded, setExpanded] = useState>(new Set()); const [agentsByPeer, setAgentsByPeer] = useState>({}); + const [pairedFps, setPairedFps] = useState>(new Set()); + const [prog, setProg] = useState(null); async function loadNetwork() { const snap = await App.networkSnapshot(); @@ -54,29 +59,58 @@ export function Network({ brand, active }: { brand: Brand; active: boolean }) { const l = await App.lanStatus(); setLan(l.ok === true && l.enabled === true); } + async function loadPaired() { + const list = await App.pairList(); + const fps = (Array.isArray(list) ? list : []).filter((r) => r.state === "approved").map((r) => r.fingerprint); + setPairedFps(new Set(fps)); + } useEffect(() => { if (!active) return; loadLan(); loadNetwork(); - const t = setInterval(loadNetwork, 5000); + loadPaired(); + const t = setInterval(() => { + loadNetwork(); + loadPaired(); + }, 5000); return () => clearInterval(t); }, [active]); + // While a pair is waiting, poll the target's agents: once it answers with + // an agent list (instead of pairing_required) the other side has approved. + useEffect(() => { + if (!prog || prog.phase !== "waiting") return; + let tries = 0; + const t = setInterval(async () => { + tries += 1; + const r = (await App.peerAgents(prog.peerId)) as Record; + if (Array.isArray(r.agents)) { + setProg((p) => (p ? { ...p, phase: "paired" } : p)); + loadPaired(); + loadNetwork(); + } else if (tries >= 24) { + setProg((p) => (p ? { ...p, phase: "failed" } : p)); + } + }, 2500); + return () => clearInterval(t); + }, [prog?.peerId, prog?.phase]); + async function sendPair(p: Peer) { setPairing(p.peer_id); const r = (await App.pairRequest(p.peer_id)) as Record; const name = p.display_name || p.peer_id; - if (r.ok && r.sent) { - const code = typeof r.code === "string" ? r.code : ""; - if (r.pending && code) setToast(`Pairing ${name} — code ${code}. Approve on that device to finish.`); - else if (r.pending) setToast(`Pairing request sent to ${name}. Approve on that device to finish.`); - else setToast(`Paired with ${name}.`); + const code = typeof r.code === "string" ? r.code : ""; + if (r.ok && r.sent && r.pending) { + setProg({ peerId: p.peer_id, name, code, phase: "waiting" }); + } else if (r.ok && r.sent) { + // Already trusted by the peer — paired immediately. + setProg({ peerId: p.peer_id, name, code, phase: "paired" }); + loadPaired(); } else { setToast(typeof r.error === "string" ? r.error : "Couldn't reach that device"); } setPairing(""); - loadNetwork(); } async function toggleExpand(p: Peer) { const next = new Set(expanded); @@ -162,9 +196,13 @@ export function Network({ brand, active }: { brand: Brand; active: boolean }) {
          {p.status ?? "—"} - + {p.metadata?.device_id && pairedFps.has(p.metadata.device_id) ? ( + Paired + ) : ( + + )}
          {open ? ( @@ -218,6 +256,10 @@ export function Network({ brand, active }: { brand: Brand; active: boolean }) {
          + {prog ? ( + setProg(null)} /> + ) : null} + setToast("")} /> ); diff --git a/desktop/packages/bridge/src/types.ts b/desktop/packages/bridge/src/types.ts index 05166d4..d1d6b58 100644 --- a/desktop/packages/bridge/src/types.ts +++ b/desktop/packages/bridge/src/types.ts @@ -30,7 +30,7 @@ export type Peer = { display_name?: string; status?: string; last_seen?: string; - metadata?: { hostname?: string; address?: string; peer_version?: string }; + metadata?: { hostname?: string; address?: string; peer_version?: string; device_id?: string }; }; export type NetworkSnapshot = From 72626b763f6aaa3c978b771dc76b4d8b2b4a851f Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 05:01:36 +0300 Subject: [PATCH 40/86] =?UTF-8?q?feat(a2a):=20unpair=20=E2=80=94=20forget?= =?UTF-8?q?=20a=20paired=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing revoke path. PairingStore.Forget deletes a record by fingerprint or code (a forgotten peer is neither trusted nor denied — a fresh contact re-enters pairing from pending). Surfaced as `clawtool peer pair forget `, the installer PairForget binding, the bridge pairForget method, and an "Unpair" button next to the Paired badge on a device row. Reversible by re-pairing. --- cmd/clawtool-installer/app.go | 1 + desktop/apps/app-ui/src/dev-stubs.ts | 1 + desktop/apps/app-ui/src/views/Network.tsx | 16 +++++++++++++++- desktop/packages/bridge/src/app.ts | 1 + internal/a2a/pairing.go | 22 ++++++++++++++++++++++ internal/a2a/pairing_test.go | 22 ++++++++++++++++++++++ internal/cli/peer_pair.go | 17 ++++++++++++----- 7 files changed, 74 insertions(+), 6 deletions(-) diff --git a/cmd/clawtool-installer/app.go b/cmd/clawtool-installer/app.go index 45f63a9..ab3162d 100644 --- a/cmd/clawtool-installer/app.go +++ b/cmd/clawtool-installer/app.go @@ -653,6 +653,7 @@ func (a *App) PairList() string { // or fingerprint — the Accept / Deny actions on the approval prompt. func (a *App) PairApprove(selector string) string { return a.pairDecide("approve", selector) } func (a *App) PairDeny(selector string) string { return a.pairDecide("deny", selector) } +func (a *App) PairForget(selector string) string { return a.pairDecide("forget", selector) } func (a *App) pairDecide(action, selector string) string { selector = strings.TrimSpace(selector) diff --git a/desktop/apps/app-ui/src/dev-stubs.ts b/desktop/apps/app-ui/src/dev-stubs.ts index d037641..c4b4e4a 100644 --- a/desktop/apps/app-ui/src/dev-stubs.ts +++ b/desktop/apps/app-ui/src/dev-stubs.ts @@ -76,6 +76,7 @@ export function installDevStubs(platform: "windows" | "darwin" = "windows") { J([{ fingerprint: "Z23WTLH3BQBYPUGRCUMYNC2SQFVPVOLGYXJK2LCHTZZ27UA2AQBA", display_name: "macbook-air", address: "192.168.1.51:60111", code: "", state: "approved" }]), PairApprove: () => J({ ok: true }), PairDeny: () => J({ ok: true }), + PairForget: () => J({ ok: true }), PairRequest: () => J({ ok: true, sent: true, pending: true, code: "4821" }), RunDoctor: () => J({ ok: true, output: "clawtool doctor — 0.22.171\n\n[binary] ✓ clawtool on PATH\n[daemon] ✓ running on 127.0.0.1:64205\n[agents] ✓ claude, codex callable\n[bridges] ⚠ gemini bridge missing\n\n1 warning, 0 errors" }), Quit: () => {}, diff --git a/desktop/apps/app-ui/src/views/Network.tsx b/desktop/apps/app-ui/src/views/Network.tsx index 5674266..49cdec6 100644 --- a/desktop/apps/app-ui/src/views/Network.tsx +++ b/desktop/apps/app-ui/src/views/Network.tsx @@ -96,6 +96,15 @@ export function Network({ brand, active }: { brand: Brand; active: boolean }) { return () => clearInterval(t); }, [prog?.peerId, prog?.phase]); + async function unpair(p: Peer) { + const fp = p.metadata?.device_id; + if (!fp) return; + setPairing(p.peer_id); + const r = (await App.pairForget(fp)) as Record; + setToast(r.ok ? `Unpaired ${p.display_name || p.peer_id}` : "Couldn't unpair"); + await loadPaired(); + setPairing(""); + } async function sendPair(p: Peer) { setPairing(p.peer_id); const r = (await App.pairRequest(p.peer_id)) as Record; @@ -197,7 +206,12 @@ export function Network({ brand, active }: { brand: Brand; active: boolean }) { {p.status ?? "—"} {p.metadata?.device_id && pairedFps.has(p.metadata.device_id) ? ( - Paired + <> + Paired + + ) : (
          {p.status ?? "—"} - {p.metadata?.device_id && pairedFps.has(p.metadata.device_id) ? ( + {p.metadata?.device_id && verifiedPaired.has(p.metadata.device_id) ? ( <> Paired +
          +
          + + {addOpen ? ( +
          +
          +
          New project
          +
          +
          The folder the agent will operate against (its current working directory).
          +
          + setCwd(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") addProject(); + if (e.key === "Escape") setAddOpen(false); + }} + /> + setLabel(e.target.value)} + /> +
          + + +
          +
          +
          + ) : null} + + + {projects.length === 0 ? ( +
          + No projects yet. Add one above — pick a folder, give it a label, and the agent will work against it. +
          + ) : ( +
          + {projects.map((p) => { + const isActive = active?.id === p.id; + return ( +
          + + + +
          + ); + })} +
          + )} + + {active ? ( +
          +
          +
          {active.label}
          + project +
          +
          {active.cwd}
          +
          + Conversation pane lands here next (Phase C). The agent runtime (namzu) is embedded and pinned to this + project's cwd; tool calls go through clawtool's MCP layer. +
          +
          + ) : null} + + setToast("")} /> + + ); +} diff --git a/desktop/packages/bridge/src/app.ts b/desktop/packages/bridge/src/app.ts index 9a7d84c..65255b6 100644 --- a/desktop/packages/bridge/src/app.ts +++ b/desktop/packages/bridge/src/app.ts @@ -11,6 +11,7 @@ import type { NetworkSnapshot, PairingRequest, PeerAgentsResult, + Project, Result, UpdateInfo, } from "./types"; @@ -61,6 +62,12 @@ export const App = { pairForget: (selector: string) => callJSON("PairForget", OK_FALSE, selector), pairRequest: (peerID: string) => callJSON("PairRequest", OK_FALSE, peerID), + // projects ledger — left sidebar anchors the agent to a cwd per project + projectsList: () => callJSON("ProjectsList", []), + projectsAdd: (label: string, cwd: string) => callJSON("ProjectsAdd", OK_FALSE, label, cwd), + projectsRemove: (id: string) => callJSON("ProjectsRemove", OK_FALSE, id), + projectsTouch: (id: string) => callJSON("ProjectsTouch", OK_FALSE, id), + // cross-device LAN exposure (discoverability) lanStatus: () => callJSON("LanStatus", { ok: false }), lanEnable: () => callJSON("LanEnable", OK_FALSE), diff --git a/desktop/packages/bridge/src/types.ts b/desktop/packages/bridge/src/types.ts index d1d6b58..5e7d0b0 100644 --- a/desktop/packages/bridge/src/types.ts +++ b/desktop/packages/bridge/src/types.ts @@ -77,6 +77,16 @@ export type PairingRequest = { export type Result = { ok: boolean; error?: string; [k: string]: unknown }; export type LanStatus = { ok: boolean; enabled?: boolean }; +// A project anchors the agent to a working directory — its conversation +// and namzu's session tree live under that cwd. +export type Project = { + id: string; + label: string; + cwd: string; + created_at?: string; + last_used_at?: string; +}; + // Wails runtime events emitted by the Go side. export type InstallStep = { level?: "ok" | "warn" | "fail"; label?: string; message?: string; raw?: string }; export type InstallDone = { ok?: boolean; summary?: string }; From a9d6fff1157bed6eb27e8851f6fda92378737f91 Mon Sep 17 00:00:00 2001 From: bahadirarda Date: Thu, 28 May 2026 18:09:13 +0300 Subject: [PATCH 48/86] feat(desktop): Conversation view + streaming event protocol (ADE Phase C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active project's detail panel becomes a real conversation pane. The plumbing is end to end — UI, bridge, Wails binding, event channel — with a placeholder responder so the streaming behaviour is observable now; Phase D swaps the responder for namzu's runtime running against the project's cwd, with clawtool MCP tools wired in. - Go: AgentSend(projectID, message) returns a turn id and streams events back over the "agent:event" Wails channel — start → delta(s) → tool-start/tool-end → done|error. - Bridge: agentSend wrapper + AgentEvent type. - Conversation component: per-project transcript (persists thanks to keep-alive routing), streaming deltas with a typewriter caret, tool-call chips, Enter to send / Shift+Enter newline. Filters events by turn id so switching projects mid-stream doesn't bleed. - Dev stubs gain a real EventsOn/EventsEmit bus so the streaming flow works under `pnpm dev` too. --- cmd/clawtool-installer/agent.go | 68 ++++++++ .../src/components/Conversation.module.css | 103 ++++++++++++ .../app-ui/src/components/Conversation.tsx | 152 ++++++++++++++++++ desktop/apps/app-ui/src/dev-stubs.ts | 34 +++- desktop/apps/app-ui/src/views/Projects.tsx | 9 +- desktop/packages/bridge/src/app.ts | 5 + desktop/packages/bridge/src/types.ts | 11 ++ 7 files changed, 374 insertions(+), 8 deletions(-) create mode 100644 cmd/clawtool-installer/agent.go create mode 100644 desktop/apps/app-ui/src/components/Conversation.module.css create mode 100644 desktop/apps/app-ui/src/components/Conversation.tsx diff --git a/cmd/clawtool-installer/agent.go b/cmd/clawtool-installer/agent.go new file mode 100644 index 0000000..4d69f8f --- /dev/null +++ b/cmd/clawtool-installer/agent.go @@ -0,0 +1,68 @@ +// Agent dispatch — the seam Conversation calls when the operator sends a +// message in a project. Phase C wires the protocol end to end with a +// placeholder responder so the UI/event plumbing is real; Phase D swaps the +// responder for namzu's actual runtime (per-project session, clawtool MCP +// tools); Phase E adds an optional peer routing path so the dispatch can run +// on a paired device's daemon instead of locally. +package main + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + wruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +// agentEvent is the single shape streamed back over Wails events. +// Kind: "start" | "delta" | "tool-start" | "tool-end" | "done" | "error". +type agentEvent struct { + TurnID string `json:"turn_id"` + ProjectID string `json:"project_id"` + Kind string `json:"kind"` + Text string `json:"text,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Error string `json:"error,omitempty"` +} + +// AgentSend kicks off a turn for the given project. Returns the turn id +// immediately; the actual events stream over the "agent:event" channel — +// the renderer subscribes via runtime.EventsOn and updates the transcript +// as deltas arrive. Empty project id is allowed (for one-off chats not +// anchored to a project). +func (a *App) AgentSend(projectID, message string) string { + turnID := uuid.NewString() + go a.runAgentTurn(turnID, projectID, message) + b, _ := json.Marshal(map[string]any{"ok": true, "turn_id": turnID}) + return string(b) +} + +// runAgentTurn is the placeholder responder. It demonstrates the streaming +// contract — start → a few deltas → done — so the renderer's typewriter + +// state machine work end to end before Phase D plugs in namzu. +func (a *App) runAgentTurn(turnID, projectID, message string) { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "start"}) + preview := message + if len(preview) > 40 { + preview = preview[:40] + "…" + } + parts := []string{ + fmt.Sprintf("Echo (Phase C placeholder): %q.", preview), + " The conversation pipe is live — Phase D will replace this responder", + " with namzu's runtime running against this project's cwd, and the", + " tool calls will route through clawtool's MCP layer.", + } + for _, p := range parts { + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "delta", Text: p}) + time.Sleep(180 * time.Millisecond) + } + a.emitAgent(agentEvent{TurnID: turnID, ProjectID: projectID, Kind: "done"}) +} + +func (a *App) emitAgent(ev agentEvent) { + if a.ctx == nil { + return + } + wruntime.EventsEmit(a.ctx, "agent:event", ev) +} diff --git a/desktop/apps/app-ui/src/components/Conversation.module.css b/desktop/apps/app-ui/src/components/Conversation.module.css new file mode 100644 index 0000000..e4f88d9 --- /dev/null +++ b/desktop/apps/app-ui/src/components/Conversation.module.css @@ -0,0 +1,103 @@ +.wrap { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + gap: 12px; +} +.transcript { + flex: 1; + min-height: 180px; + max-height: 60vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding: 6px 2px; +} +.msg { + display: flex; + gap: 10px; + align-items: flex-start; +} +.role { + width: 32px; + height: 32px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + flex: none; +} +.user { + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); +} +.assistant { + background: var(--surface-recessed); + color: var(--text-secondary); + border: 1px solid var(--hairline); +} +.body { + flex: 1; + min-width: 0; + font-size: 13.5px; + line-height: 1.55; + color: var(--text); + white-space: pre-wrap; + padding-top: 5px; +} +.body.dim { + color: var(--text-secondary); +} +.tool { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 4px 0; + padding: 4px 9px; + border-radius: var(--radius-sm); + background: var(--surface-recessed); + border: 1px solid var(--hairline); + font-size: 12px; + color: var(--text-secondary); + font-family: var(--font-mono); +} +.caret { + display: inline-block; + width: 7px; + height: 14px; + background: var(--accent); + vertical-align: -2px; + margin-left: 1px; + animation: caret 0.9s steps(2) infinite; +} +@keyframes caret { + 0%, 49% { opacity: 1; } + 50%, 100% { opacity: 0; } +} +.composer { + display: flex; + gap: 8px; + align-items: flex-end; + border-top: 1px solid var(--hairline); + padding-top: 12px; +} +.composer textarea { + flex: 1; + font: inherit; + font-size: 13.5px; + line-height: 1.5; + padding: 10px 12px; + border: 1px solid var(--hairline); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); + resize: none; + min-height: 44px; + max-height: 160px; +} +.composer textarea:focus { outline: none; border-color: var(--accent); } +.empty { color: var(--text-secondary); font-size: 13px; padding: 14px 6px; } diff --git a/desktop/apps/app-ui/src/components/Conversation.tsx b/desktop/apps/app-ui/src/components/Conversation.tsx new file mode 100644 index 0000000..83d9c6a --- /dev/null +++ b/desktop/apps/app-ui/src/components/Conversation.tsx @@ -0,0 +1,152 @@ +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { Button } from "@clawtool/design-system"; +import { App, on, type AgentEvent, type Project } from "@clawtool/bridge"; +import styles from "./Conversation.module.css"; + +type Msg = + | { kind: "user"; text: string } + | { kind: "assistant"; text: string; tools: string[]; streaming: boolean }; + +// Per-project conversation transcript. Mounted once per project (the +// containing view stays mounted thanks to keep-alive routing) so the +// transcript persists when the user navigates away and comes back. +export function Conversation({ project }: { project: Project }) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [sending, setSending] = useState(false); + const turnRef = useRef(""); + const scrollRef = useRef(null); + const taRef = useRef(null); + + // Subscribe to the agent event stream for THIS project. We filter by + // turn id so deltas from a different project's pending turn (e.g. user + // switched projects mid-stream) don't bleed into this transcript. + useEffect(() => { + const off = on("agent:event", (ev) => { + if (!turnRef.current || ev.turn_id !== turnRef.current) return; + if (ev.kind === "delta") { + setMessages((m) => { + const last = m[m.length - 1]; + if (!last || last.kind !== "assistant") return m; + const next = m.slice(0, -1); + next.push({ ...last, text: last.text + (ev.text ?? "") }); + return next; + }); + } else if (ev.kind === "tool-start" && ev.tool_name) { + setMessages((m) => { + const last = m[m.length - 1]; + if (!last || last.kind !== "assistant") return m; + const next = m.slice(0, -1); + next.push({ ...last, tools: [...last.tools, ev.tool_name as string] }); + return next; + }); + } else if (ev.kind === "done" || ev.kind === "error") { + setMessages((m) => { + const last = m[m.length - 1]; + if (!last || last.kind !== "assistant") return m; + const next = m.slice(0, -1); + const errSuffix = ev.kind === "error" && ev.error ? `\n\n[error: ${ev.error}]` : ""; + next.push({ ...last, streaming: false, text: last.text + errSuffix }); + return next; + }); + setSending(false); + turnRef.current = ""; + } + }); + return () => off(); + }, []); + + useEffect(() => { + const el = scrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [messages]); + + async function send() { + const text = input.trim(); + if (!text || sending) return; + setSending(true); + setInput(""); + setMessages((m) => [ + ...m, + { kind: "user", text }, + { kind: "assistant", text: "", tools: [], streaming: true }, + ]); + const r = (await App.agentSend(project.id, text)) as Record; + if (r.ok && typeof r.turn_id === "string") { + turnRef.current = r.turn_id; + } else { + // Surface the immediate failure in the assistant slot. + setMessages((m) => { + const last = m[m.length - 1]; + if (!last || last.kind !== "assistant") return m; + const next = m.slice(0, -1); + next.push({ + ...last, + streaming: false, + text: typeof r.error === "string" ? `[error: ${r.error}]` : "[error sending message]", + }); + return next; + }); + setSending(false); + } + } + + function onKey(e: KeyboardEvent) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + send(); + } + } + + return ( +
          +
          + {messages.length === 0 ? ( +
          + Working in {project.cwd}. Ask the agent for anything. +
          + ) : ( + messages.map((m, i) => ( +
          +
          + {m.kind === "user" ? "YOU" : "AGT"} +
          +
          + {m.kind === "assistant" && m.tools.length > 0 ? ( +
          + {m.tools.map((t, j) => ( + + ⚙ {t} + + ))} +
          + ) : null} + {m.kind === "assistant" && m.streaming && !m.text ? ( + + ) : ( + <> + {m.text} + {m.kind === "assistant" && m.streaming ? : null} + + )} +
          +
          + )) + )} +
          +
          +