From 236a2c38366422986d6c006a15fd37ef028c37a9 Mon Sep 17 00:00:00 2001 From: imsobear Date: Tue, 13 Oct 2026 21:52:30 -0700 Subject: [PATCH 1/6] Add a desktop app: native screens in Go, the engine beside it in Node. Loopable for one Mac, in desktop/: the inbox, loops, connectors and agents as native UI drawn by MyGo, with no account and nothing hosted. Everything that is not a screen is the same TypeScript as the service, bundled into one file (src/desktop/engine.ts) that the app runs as a Node process: the dispatcher, a runner in the same process, and a JSON API on loopback that only the app's per-launch token can call. GitHub connects by device flow, since a public client can keep no secret; Google by a desktop client coming back to the engine's loopback address. The screens draw without a window in tests, and one test loads them from a real engine in a throwaway folder. CI vets, tests and builds the app on macOS. How it fits together is in docs/desktop.md. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 33 + .gitignore | 3 + AGENTS.md | 1 + .../.agents/skills/mygo-maintenance/SKILL.md | 37 + .../mygo-maintenance/agents/openai.yaml | 3 + desktop/.gitignore | 4 + desktop/agents.go | 197 ++++++ desktop/app.go | 502 ++++++++++++++ desktop/connectors.go | 641 ++++++++++++++++++ desktop/engine.go | 358 ++++++++++ desktop/engine_test.go | 186 +++++ desktop/go.mod | 13 + desktop/go.sum | 10 + desktop/icons.go | 106 +++ desktop/inbox.go | 316 +++++++++ desktop/loopform.go | 419 ++++++++++++ desktop/loops.go | 560 +++++++++++++++ desktop/main.go | 38 ++ desktop/model.go | 517 ++++++++++++++ desktop/mygo.json | 13 + desktop/node.go | 138 ++++ desktop/render_test.go | 150 ++++ desktop/resources/icon.png | Bin 0 -> 87043 bytes desktop/settings.go | 169 +++++ desktop/shell.go | 290 ++++++++ docs/desktop.md | 57 ++ package.json | 2 + scripts/build-engine.mjs | 65 ++ src/connectors/github/device.test.ts | 74 ++ src/connectors/github/device.ts | 120 ++++ src/connectors/github/runtime.ts | 42 ++ src/connectors/oauth-app.test.ts | 13 + src/connectors/oauth-app.ts | 12 +- src/connectors/types.ts | 22 + src/desktop/api.ts | 291 ++++++++ src/desktop/engine.ts | 211 ++++++ src/desktop/local-runner.ts | 107 +++ src/desktop/watch-file.ts | 25 + 38 files changed, 5744 insertions(+), 1 deletion(-) create mode 100644 desktop/.agents/skills/mygo-maintenance/SKILL.md create mode 100644 desktop/.agents/skills/mygo-maintenance/agents/openai.yaml create mode 100644 desktop/.gitignore create mode 100644 desktop/agents.go create mode 100644 desktop/app.go create mode 100644 desktop/connectors.go create mode 100644 desktop/engine.go create mode 100644 desktop/engine_test.go create mode 100644 desktop/go.mod create mode 100644 desktop/go.sum create mode 100644 desktop/icons.go create mode 100644 desktop/inbox.go create mode 100644 desktop/loopform.go create mode 100644 desktop/loops.go create mode 100644 desktop/main.go create mode 100644 desktop/model.go create mode 100644 desktop/mygo.json create mode 100644 desktop/node.go create mode 100644 desktop/render_test.go create mode 100644 desktop/resources/icon.png create mode 100644 desktop/settings.go create mode 100644 desktop/shell.go create mode 100644 docs/desktop.md create mode 100644 scripts/build-engine.mjs create mode 100644 src/connectors/github/device.test.ts create mode 100644 src/connectors/github/device.ts create mode 100644 src/desktop/api.ts create mode 100644 src/desktop/engine.ts create mode 100644 src/desktop/local-runner.ts create mode 100644 src/desktop/watch-file.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f64ef2d..1ff6643 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,36 @@ jobs: - name: Build the CLI run: pnpm build + + desktop: + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - uses: actions/setup-go@v5 + with: + go-version-file: desktop/go.mod + cache-dependency-path: desktop/go.sum + - run: pnpm install --frozen-lockfile + + # The engine the app ships: the dispatcher, a runner and the API, in one file. + - name: Build the engine + run: pnpm build:engine + + - name: Vet the app + working-directory: desktop + run: go vet ./... && go tool mygo vet . + + # Draws every screen without a window, and loads them from a real engine. + - name: Test the app + working-directory: desktop + run: CGO_ENABLED=0 go test ./... + + - name: Build the app + working-directory: desktop + run: go build ./... diff --git a/.gitignore b/.gitignore index 91f029d..fe98771 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ config/oauth-app.json # Trying hosted mode locally (docs/hosting.md) .hosted/ + +# The desktop app's engine, built by scripts/build-engine.mjs (docs/desktop.md) +desktop/resources/engine/ diff --git a/AGENTS.md b/AGENTS.md index e805baa..63ca264 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ Work comes in from GitHub, Slack, Lark, email, or the clock. A loop says what to - Tailwind CSS with shadcn/ui - SQLite for state (libSQL when hosted), encrypted secrets when hosted, the macOS keychain (or a file elsewhere) in `pnpm dev` - Hosted on Cloudflare: the app as a Worker, the dispatcher as a Container; see `docs/hosting.md` +- A desktop app in `desktop/`, native UI in Go with MyGo, running the same engine (dispatcher, runner, API) as a Node process beside it; see `docs/desktop.md` ## Workspaces diff --git a/desktop/.agents/skills/mygo-maintenance/SKILL.md b/desktop/.agents/skills/mygo-maintenance/SKILL.md new file mode 100644 index 0000000..9839fdc --- /dev/null +++ b/desktop/.agents/skills/mygo-maintenance/SKILL.md @@ -0,0 +1,37 @@ +--- +name: mygo-maintenance +description: Maintain Go applications using github.com/egoist/mygo, including UI changes, crash fixes, and dependency upgrades. Apply MyGo-specific lifetime, identity, threading, and testing practices. +--- + +# MyGo app maintenance + +Check the app's pinned MyGo version and existing conventions before changing APIs. Locate its source with `go list -m -f '{{.Dir}}' github.com/egoist/mygo`; read `ui/doc.go` and relevant implementation comments. Read `docs/ui/migration.md` only for major-version upgrades, including minor-version bumps while on `0.x` (for example, `0.2.x` to `0.3.x`, or `1.x` to `2.x`). Confirm API availability against the pinned source. + +The native UI rules below apply where the app uses package `ui`; preserve a web app's frontend and typed Go/TypeScript bindings. + +## UI lifetime and identity + +- Views receive a stable `*ui.Context` for their window. `Children(func())` shares it and changes its parent scope during the callback. Keep Context out of app state and workers; capture `c.Services()` for persistent clipboard/URL callbacks and background redraws. +- Constructors return checked `ui.Element` values. Elements and custom parts expire before the next build pass, including another pass in the same frame. Store app data, widget state and `ui.Handle`, never an element or parts. Zero elements are safe and `Valid()` is a quiet check; stale operations panic in development/Tester and become empty results or no-ops in production. Fix the ownership rather than relying on validity guards or recovery. +- Bind persistent identity with `.Bind(&handle)`. `handle.Focused(c)` and `FocusWithin(c)` read committed focus independently of construction order. `handle.Focus(c)` requests focus and waits while hidden; `CancelFocus(c)` cancels it. Handles support several windows; select one with its Context, and keep widget state separate per window. +- Use `.FocusBind(&a.pane, files)` when app data should request focus. The field holds desired focus; `ui.FocusedValue(c, &a.pane)` reads actual focus while a hidden target waits. Use a comparable field, unique target values and its zero value for no focus. +- Use `handle.Bounds(c)` for committed geometry in input callbacks. `c.Resolve(handle)` returns only the current build's element. Do not capture an element in a later input callback to query its bounds. +- Give controls stable, unique identity before construction with `c.Key(key)`, including stateful widgets. Container `.Key(key)` must precede children and local state. Configure fluent lists before `.Rows`; use `.ItemKey` for stable item identity and `ui.ListRow.Selected()`/`ListFocused()` for row styling. + +## Input and actions + +- Bound values update after construction and configuration. `Changed`/`Submitted` notices appear in the following build pass. Bind directly to persistent model fields when possible. A local value recomputed each pass loses the edit before its notice; write derived checkbox or segmented values back in `OnClick`, after that build's bound input updates them. +- `OnClick`/`OnShortcut` run after construction and bound input; `OnChange`/`OnSubmit` observe the rebuilt view's notices. Prefer deferred actions when changing a collection being built. `Clicked()` and shortcut queries remain supported for local interactions. +- Declare scoped commands with `handle.OnShortcut(c, mods, key, fn)`, before or after Bind. Only an enabled control present in the current build takes its command. Use `c.OnShortcut` for commands in the current parent scope. + +## State, work, and rendering + +- Views may rebuild several times for one event. Trigger external side effects through handled actions or explicit guarded jobs, not unconditional rendering code. +- Keep slow I/O and expensive computation off the UI thread. Publish results through `Window.Update`; it is asynchronous. `Invalidate` requests redraws without synchronizing model writes. Cancel obsolete jobs or reject stale results after source changes or window closure. +- Keep drawing callbacks limited to painting; they can run repeatedly. Prefer MyGo widgets/base widgets to preserve keyboard, focus, disabled, and accessibility behavior. Follow the app's theme and sizing conventions. + +## Verification + +- Reproduce UI failures with `ui.NewTester`. Exercise extra frames while elements disappear and return, plus relevant focus, typing, and shortcut behavior. +- Run targeted regression tests, `go tool mygo vet .` for stored build values and goroutine captures, and `CGO_ENABLED=0 go test ./...`. Use race checks when changing shared state, and inspect rendered output or the native app when changing layout/platform behavior, respecting the user's verification preferences. +- For upgrades, preview with `go tool mygo migrate-ui .`, apply with `-write`, and review persistent identity/services, separately assigned keys and derived bound values manually. Verify affected API/lifecycle assumptions against the new pinned source and rerun relevant UI tests. Report remaining platform-specific verification limits. diff --git a/desktop/.agents/skills/mygo-maintenance/agents/openai.yaml b/desktop/.agents/skills/mygo-maintenance/agents/openai.yaml new file mode 100644 index 0000000..a8ed1db --- /dev/null +++ b/desktop/.agents/skills/mygo-maintenance/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "MyGo Maintenance" + short_description: "Maintain MyGo apps with safe UI lifetimes" diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 0000000..48d63f6 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,4 @@ +/build/ +/.mygo/ +mygo_windows_*.syso +/desktop diff --git a/desktop/agents.go b/desktop/agents.go new file mode 100644 index 0000000..03bfb6d --- /dev/null +++ b/desktop/agents.go @@ -0,0 +1,197 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" +) + +var modeLabels = map[string]string{ + "read_only": "Read only", + "workspace_write": "Can write in the workspace", +} + +type agentEdit struct { + open bool + mode string + model string + seconds string + testOutput string + from string // settings this was made from, to start over on a change elsewhere +} + +func (a *app) agentsPage(c *ui.Context) { + t := c.Theme() + a.header(c, "", "", "Agents", "The coding CLIs on this Mac. Loops run with one of them.", "", nil) + if a.m == nil { + ui.Spinner(c) + return + } + if len(a.agents) == 0 { + ui.Spinner(c) + return + } + card(c).Dividers(1, t.Border).Children(func() { + for _, manifest := range a.m.Agents { + for i := range a.agents { + if a.agents[i].AgentID == manifest.ID { + a.agentRow(c, &manifest, &a.agents[i]) + } + } + } + }) +} + +func presence(view *AgentView) string { + if view.Auth != nil && view.Auth.SignedIn { + if view.Auth.Detail != "" { + return "Signed in · " + view.Auth.Detail + } + return "Signed in" + } + if view.Installed { + if view.Auth != nil && view.Auth.Detail != "" { + return "Installed, not signed in · " + view.Auth.Detail + } + return "Installed, not signed in" + } + return "Not installed" +} + +func (a *app) agentRow(c *ui.Context, manifest *AgentManifest, view *AgentView) { + t := c.Theme() + edit := a.agentEdit[manifest.ID] + signature := fmt.Sprintf("%s|%s|%v", view.Settings.PermissionMode, deref(view.Settings.Model), view.Settings.TimeoutMs) + if edit == nil { + edit = &agentEdit{open: view.IsDefault && !view.DefaultIsImplicit} + a.agentEdit[manifest.ID] = edit + } + if edit.from != signature { + edit.from = signature + edit.mode = modeLabels[view.Settings.PermissionMode] + edit.model = deref(view.Settings.Model) + edit.seconds = itoa(int(view.Settings.TimeoutMs/1000 + 0.5)) + } + online := view.Auth != nil && view.Auth.SignedIn + busy := a.busy["agent:"+manifest.ID] + + ui.Column(c).Key(manifest.ID).Children(func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Padding(12, 16).Children(func() { + connectorTile(c, manifest.ID, 32, !online) + head := ui.ButtonBase(c).Grow(1).Column().AlignItems(ui.Start).Gap(2).Label(manifest.Name) + head.Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, manifest.Name).FontSize(13).Bold() + if view.IsDefault { + ui.Badge(c, "Default") + } + }) + ui.Text(c, presence(view)).FontSize(11).TextColor(t.TextMuted).SingleLine() + }) + if head.Clicked() { + edit.open = !edit.open + } + if !view.IsDefault { + if ui.Button(c, "Make default").Disabled(busy).Clicked() { + id, name := manifest.ID, manifest.Name + a.run("agent:"+id, func() (string, error) { + if err := a.call("chooseDefaultAgent", map[string]string{"agentId": id}, nil); err != nil { + return "", err + } + return name + " will run the loops", nil + }) + } + } + label := "Settings" + if edit.open { + label = "Hide" + } + if ui.Button(c, label).Clicked() { + edit.open = !edit.open + } + }) + if !edit.open { + return + } + ui.Column(c).Gap(12).Padding(14, 16, 16, 60).BorderWidth(1, 0, 0, 0).BorderColor(t.Border).Background(t.Background).Children(func() { + if !online { + ui.Text(c, manifest.InstallHint).FontSize(12).TextColor(t.TextMuted) + } + ui.Row(c).Gap(16).AlignItems(ui.Start).Wrap().Children(func() { + ui.Field(c, "Permission", func() { + labels := make([]string, len(manifest.PermissionModes)) + for i, mode := range manifest.PermissionModes { + labels[i] = modeLabels[mode] + } + ui.Select(c, &edit.mode, labels).Width(260) + }) + if manifest.SupportsModel { + ui.Field(c, "Model", func() { + ui.TextInput(c, &edit.model).Placeholder(manifest.ModelPlaceholder).Width(220) + }) + } + ui.Field(c, "Timeout (seconds)", func() { + ui.TextInput(c, &edit.seconds).Width(120) + }) + }) + mono(c, view.CommandPreview) + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + saveLabel := "Save" + if busy { + saveLabel = "Working…" + } + if ui.PrimaryButton(c, saveLabel).Disabled(busy).Clicked() { + mode := view.Settings.PermissionMode + for value, label := range modeLabels { + if label == edit.mode { + mode = value + } + } + seconds, _ := strconv.Atoi(strings.TrimSpace(edit.seconds)) + var model *string + if m := strings.TrimSpace(edit.model); m != "" { + model = &m + } + id, name := manifest.ID, manifest.Name + body := map[string]any{"agentId": id, "permissionMode": mode, "model": model, "timeoutMs": seconds * 1000} + a.run("agent:"+id, func() (string, error) { + if err := a.call("saveAgent", body, nil); err != nil { + return "", err + } + return "Saved " + name + " settings", nil + }) + } + if view.Installed { + if ui.Button(c, "Test").Disabled(busy).Clicked() { + edit.testOutput = "" + id, name := manifest.ID, manifest.Name + a.run("agent:"+id, func() (string, error) { + var result AgentTest + if err := a.call("runAgentTest", map[string]string{"agentId": id}, &result); err != nil { + return "", err + } + text := fmt.Sprintf("Answered correctly in %.1fs.", result.DurationMs/1000) + if !result.Passed { + detail := "The agent did not answer as expected." + if result.Detail != nil { + detail = *result.Detail + } + text = strings.TrimSpace(detail + "\n" + result.Output) + } + a.update(func() { edit.testOutput = text }) + if result.Passed { + return name + " is working", nil + } + return "", fmt.Errorf("%s did not pass", name) + }) + } + } + }) + if edit.testOutput != "" { + ui.Text(c, edit.testOutput).FontSize(12).TextColor(t.TextMuted).Selectable() + } + }) + }) +} diff --git a/desktop/app.go b/desktop/app.go new file mode 100644 index 0000000..83401c8 --- /dev/null +++ b/desktop/app.go @@ -0,0 +1,502 @@ +package main + +import ( + "fmt" + "strings" + "sync/atomic" + "time" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" +) + +// app is everything the window shows. All of it is read and written on +// the main thread: goroutines hand their results over with win.Update. +type app struct { + win *mygo.Window + tray *mygo.Tray + router *ui.Router + engine *Engine + quitting bool + + m *Manifests + loops []LoopView + readiness *LoopReadiness + tasks []TaskView + runnable []AgentChoice + agents []AgentView + overview []ConnectorOverview + status *Status + + // The task page, the loop page, and the connector page, each for one id. + task taskPage + loopPage loopPage + + busy map[string]bool + toasts []string + forms map[string]*loopForm + fields map[string]*fieldValues + ask askState + connect *connectFlow + agentEdit map[string]*agentEdit + confirm confirmState + open map[string]bool + settings settingsPage + + // Task states last seen, to notice one finishing. + seen map[string]string + refreshing atomic.Bool + lastTick time.Time +} + +type taskPage struct { + id string + task *TaskView + log string +} + +type loopPage struct { + id string + loop *LoopView + tasks []TaskView + poll *LoopPollState + tab int + tryURL string + dryRun bool +} + +type askState struct { + open bool + prompt string + agent string + running bool +} + +type confirmState struct { + open bool + kind string // "loop" or "connection" + id string + name string + title string + body string +} + +func newApp() *app { + a := &app{ + router: ui.NewRouter("/inbox"), + busy: map[string]bool{}, + forms: map[string]*loopForm{}, + fields: map[string]*fieldValues{}, + agentEdit: map[string]*agentEdit{}, + open: map[string]bool{}, + seen: map[string]string{}, + } + a.loopPage.dryRun = true + a.engine = newEngine(func() { a.update(func() {}) }) + return a +} + +// update runs fn on the main thread and draws a frame. Safe before the +// window exists, when there is nothing to draw. +func (a *app) update(fn func()) { + if a.win == nil { + // No window, as in tests: there is no main thread to wait for. + fn() + return + } + if a.win.IsDestroyed() { + return + } + a.win.Update(fn) +} + +func (a *app) toast(message string) { + a.toasts = append(a.toasts, message) +} + +func errText(err error) string { + if err == nil { + return "" + } + return strings.TrimSpace(err.Error()) +} + +// run does work off the main thread, then says how it went and refreshes. +// key marks what is busy meanwhile, so its buttons can wait. +func (a *app) run(key string, work func() (string, error)) { + if a.busy[key] { + return + } + a.busy[key] = true + go func() { + message, err := work() + a.update(func() { + delete(a.busy, key) + if err != nil { + a.toast(errText(err)) + } else if message != "" { + a.toast(message) + } + a.refresh() + }) + }() +} + +func (a *app) call(name string, in any, out any) error { + return a.engine.Call(name, in, out) +} + +// refresh loads what the page on screen needs. Called on the main thread; +// the loading happens elsewhere and lands with update. +func (a *app) refresh() { + if a.engine == nil { + return + } + state, _, _, _, _ := a.engine.snapshot() + if state != "running" { + return + } + if !a.refreshing.CompareAndSwap(false, true) { + return + } + path := a.router.Path() + needManifests := a.m == nil + taskID, loopID := a.task.id, a.loopPage.id + go func() { + defer a.refreshing.Store(false) + var m *Manifests + if needManifests { + var got Manifests + if err := a.call("getManifests", nil, &got); err == nil { + m = &got + } + } + var inbox struct { + Tasks []TaskView `json:"tasks"` + Agents []AgentChoice `json:"agents"` + } + inboxErr := a.call("getInbox", nil, &inbox) + + var page struct { + Loops []LoopView `json:"loops"` + Readiness LoopReadiness `json:"readiness"` + } + var loopsErr error = errSkip + if strings.HasPrefix(path, "/loops") || strings.HasPrefix(path, "/connectors/") { + loopsErr = a.call("getLoopsPage", nil, &page) + } + + var task *TaskView + var log string + if strings.HasPrefix(path, "/inbox/") && taskID != "" { + var t TaskView + if err := a.call("getTaskById", map[string]string{"id": taskID}, &t); err == nil && t.ID != "" { + task = &t + var text *string + if a.call("getTaskLog", map[string]string{"id": taskID}, &text) == nil && text != nil { + log = *text + } + } + } + + var lp *loopPage + if strings.HasPrefix(path, "/loops/") && loopID != "" && path != "/loops/new" && !strings.HasPrefix(path, "/loops/new/") { + var l LoopView + if err := a.call("getLoopById", map[string]string{"id": loopID}, &l); err == nil && l.ID != "" { + lp = &loopPage{id: loopID, loop: &l} + _ = a.call("getLoopTasks", map[string]string{"loopId": loopID}, &lp.tasks) + var poll LoopPollState + if a.call("getLoopPollState", map[string]string{"id": loopID}, &poll) == nil { + lp.poll = &poll + } + } + } + + var agents struct { + Agents []AgentView `json:"agents"` + } + agentsErr := errSkip + if path == "/agents" { + agentsErr = a.call("getAgentsPage", nil, &agents) + } + + var overview []ConnectorOverview + overviewErr := errSkip + if strings.HasPrefix(path, "/connectors") || strings.HasPrefix(path, "/loops") { + overviewErr = a.call("getConnectorOverview", nil, &overview) + } + + var status *Status + if path == "/settings" { + var s Status + if a.call("getStatus", nil, &s) == nil { + status = &s + } + } + + a.update(func() { + if m != nil { + a.m = m + } + if inboxErr == nil { + a.noticeFinished(inbox.Tasks) + a.tasks, a.runnable = inbox.Tasks, inbox.Agents + if a.ask.agent == "" && len(inbox.Agents) > 0 { + a.ask.agent = inbox.Agents[0].Name + } + } + if loopsErr == nil { + a.loops, a.readiness = page.Loops, &page.Readiness + } + if task != nil && a.task.id == task.ID { + a.task.task, a.task.log = task, log + } + if lp != nil && a.loopPage.id == lp.id { + a.loopPage.loop, a.loopPage.tasks, a.loopPage.poll = lp.loop, lp.tasks, lp.poll + } + if agentsErr == nil { + a.agents = agents.Agents + } + if overviewErr == nil { + a.overview = overview + } + if status != nil { + a.status = status + } + }) + }() +} + +type skipError struct{} + +func (skipError) Error() string { return "skipped" } + +var errSkip error = skipError{} + +// noticeFinished tells the person about a run that ended while they were +// not looking, once per run. +func (a *app) noticeFinished(tasks []TaskView) { + first := len(a.seen) == 0 + for i := range tasks { + t := &tasks[i] + before, known := a.seen[t.ID] + a.seen[t.ID] = t.State + if first || !known || before == t.State || t.Active() { + continue + } + if a.win != nil && a.win.IsVisible() && a.win.IsFocused() { + continue + } + if !mygo.NotificationsSupported() { + continue + } + title := t.Title() + body := "" + switch t.State { + case "done": + body = "Written: " + a.m.destinationLabel(t.ActionConnectorID, t.ActionID) + case "failed": + body = "Failed" + if t.Error != nil { + body = "Failed: " + firstLine(*t.Error) + } + case "skipped": + body = "Nothing to write" + case "prepared": + body = "Prepared, nothing written" + default: + continue + } + id := t.ID + n := mygo.NewNotification(mygo.NotificationOptions{Title: title, Body: body}) + n.OnClick(func() { + a.update(func() { + a.show() + a.goTo("/inbox/" + id) + }) + }) + _ = n.Show() + } +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} + +// goTo opens a page and starts loading what it needs. +func (a *app) goTo(path string) { + a.enter(path) + a.router.Push(path) + a.refresh() +} + +// enter notes which record a page is about before it is built. +func (a *app) enter(path string) { + if rest, ok := strings.CutPrefix(path, "/inbox/"); ok && rest != "" { + if a.task.id != rest { + a.task = taskPage{id: rest} + } + } + if rest, ok := strings.CutPrefix(path, "/loops/"); ok && rest != "" && rest != "new" && !strings.HasPrefix(rest, "new/") { + id := rest + if i := strings.IndexByte(id, '?'); i >= 0 { + id = id[:i] + } + if a.loopPage.id != id { + a.loopPage = loopPage{id: id, dryRun: true} + } + } +} + +func (a *app) show() { + if a.win == nil { + return + } + a.win.Show() + a.win.Focus() + a.refresh() +} + +func (a *app) ready() { + a.win = mygo.NewWindow(mygo.WindowOptions{ + Title: "Loopable", + Width: 1040, + Height: 700, + MinWidth: 760, + MinHeight: 520, + StateKey: "main", + Hidden: mygo.App.WasOpenedAtLogin(), + Content: ui.View(a.view), + }) + // Closing the window leaves the app in the menu bar, with the loops running. + a.win.OnClose(func(e *mygo.CloseEvent) { + if !a.quitting { + e.PreventDefault() + a.win.Hide() + } + }) + a.makeMenus() + a.engine.Start() + go a.ticker() +} + +// ticker refreshes the page every couple of seconds while something runs, +// and every ten otherwise, so the inbox is live without anyone pressing +// anything. Notifications come from the same look. +func (a *app) ticker() { + for { + time.Sleep(2 * time.Second) + a.update(func() { + active := false + for i := range a.tasks { + if a.tasks[i].Active() { + active = true + break + } + } + visible := a.win != nil && a.win.IsVisible() + every := 10 * time.Second + if active { + every = 2 * time.Second + } else if !visible { + every = 20 * time.Second + } + if time.Since(a.lastTick) < every { + return + } + a.lastTick = time.Now() + a.refresh() + }) + } +} + +func (a *app) makeMenus() { + mygo.App.SetMenu(mygo.NewMenu([]*mygo.MenuItem{ + {Role: mygo.RoleAppMenu}, + {Label: "File", Submenu: []*mygo.MenuItem{ + {Label: "New Loop", Accelerator: "CmdOrCtrl+N", Click: func(*mygo.MenuItem, *mygo.Window) { + a.show() + a.goTo("/loops/new") + }}, + {Label: "Ask an Agent", Accelerator: "CmdOrCtrl+K", Click: func(*mygo.MenuItem, *mygo.Window) { + a.show() + a.goTo("/inbox") + a.ask.open = true + }}, + mygo.Separator(), + {Role: mygo.RoleClose}, + }}, + {Role: mygo.RoleEditMenu}, + {Label: "View", Submenu: []*mygo.MenuItem{ + {Label: "Inbox", Accelerator: "CmdOrCtrl+1", Click: func(*mygo.MenuItem, *mygo.Window) { a.goTo("/inbox") }}, + {Label: "Loops", Accelerator: "CmdOrCtrl+2", Click: func(*mygo.MenuItem, *mygo.Window) { a.goTo("/loops") }}, + {Label: "Connectors", Accelerator: "CmdOrCtrl+3", Click: func(*mygo.MenuItem, *mygo.Window) { a.goTo("/connectors") }}, + {Label: "Agents", Accelerator: "CmdOrCtrl+4", Click: func(*mygo.MenuItem, *mygo.Window) { a.goTo("/agents") }}, + {Label: "Settings", Accelerator: "CmdOrCtrl+,", Click: func(*mygo.MenuItem, *mygo.Window) { a.goTo("/settings") }}, + mygo.Separator(), + {Label: "Reload", Accelerator: "CmdOrCtrl+R", Click: func(*mygo.MenuItem, *mygo.Window) { a.refresh() }}, + }}, + {Role: mygo.RoleWindowMenu}, + })) + + tray, err := mygo.NewTray(mygo.TrayOptions{ + Icon: trayIcon(), + IconIsTemplate: true, + ToolTip: "Loopable", + Menu: mygo.NewMenu([]*mygo.MenuItem{ + {Label: "Open Loopable", Click: func(*mygo.MenuItem, *mygo.Window) { a.show() }}, + {Label: "Inbox", Click: func(*mygo.MenuItem, *mygo.Window) { a.show(); a.goTo("/inbox") }}, + {Label: "Loops", Click: func(*mygo.MenuItem, *mygo.Window) { a.show(); a.goTo("/loops") }}, + mygo.Separator(), + {Role: mygo.RoleQuit}, + }), + }) + if err == nil { + a.tray = tray + } +} + +// ago says how long since an ISO time: "just now", "5m ago", "3h ago", or a date. +func ago(iso string) string { + t, err := time.Parse(time.RFC3339Nano, iso) + if err != nil { + return iso + } + seconds := int(time.Since(t).Seconds()) + switch { + case seconds < 60: + return "just now" + case seconds < 3600: + return fmt.Sprintf("%dm ago", (seconds+30)/60) + case seconds < 86400: + return fmt.Sprintf("%dh ago", (seconds+1800)/3600) + } + return t.Local().Format("Jan 2, 2006") +} + +func localTime(iso string) string { + t, err := time.Parse(time.RFC3339Nano, iso) + if err != nil { + return iso + } + return t.Local().Format("Jan 2, 2006 15:04") +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} + +func contains(list []string, s string) bool { + for _, item := range list { + if item == s { + return true + } + } + return false +} diff --git a/desktop/connectors.go b/desktop/connectors.go new file mode 100644 index 0000000..7f0a736 --- /dev/null +++ b/desktop/connectors.go @@ -0,0 +1,641 @@ +package main + +import ( + "encoding/base64" + "strings" + "time" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" +) + +func statusBadge(c *ui.Context, status string) { + label := map[string]string{ + "connected": "Connected", "needs_reauth": "Reconnect", "error": "Error", "paused": "Paused", + }[status] + if label == "" { + label = status + } + ui.Badge(c, label) +} + +func (a *app) overviewOf(id string) *ConnectorOverview { + for i := range a.overview { + if a.overview[i].ConnectorID == id { + return &a.overview[i] + } + } + return nil +} + +func (a *app) connectorsPage(c *ui.Context) { + t := c.Theme() + a.header(c, "", "", "Connectors", "The services loops watch and write to.", "", nil) + if a.m == nil { + ui.Spinner(c) + return + } + card(c).Dividers(1, t.Border).Children(func() { + for i := range a.m.Connectors { + manifest := &a.m.Connectors[i] + if !a.m.needsAccount(manifest.ID) { + continue + } + state := a.overviewOf(manifest.ID) + row := ui.ButtonBase(c).Key(manifest.ID).Row().Gap(12).AlignItems(ui.Center).Padding(12, 16).Label(manifest.Name) + if row.Hovered() { + row.Background(t.SurfaceHover) + } + row.Children(func() { + connectorTile(c, manifest.ID, 32, false) + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, manifest.Name).FontSize(13).Bold() + switch { + case state != nil && !state.Readiness.Ready: + ui.Text(c, state.Readiness.Reason).FontSize(11).TextColor(t.TextMuted).SingleLine() + case state != nil && len(state.Connections) > 0: + ui.Row(c).Gap(10).AlignItems(ui.Center).Wrap().Children(func() { + for _, connection := range state.Connections { + ui.Row(c).Gap(5).AlignItems(ui.Center).Children(func() { + ui.Text(c, connection.AccountLabel).FontSize(11).TextColor(t.TextMuted) + statusBadge(c, connection.Status) + }) + } + }) + default: + ui.Text(c, "No "+a.m.connectionNoun(manifest.ID)+" yet").FontSize(11).TextColor(t.TextMuted) + } + }) + ui.Icon(c, iconChevron).TextColor(t.TextMuted) + }) + if row.Clicked() { + a.goTo("/connectors/" + manifest.ID) + } + } + }) +} + +// connectFlow is a connection being made: a device code, a browser page, +// a QR code, or a token pasted. +type connectFlow struct { + connectorID string + kind string + loading bool + // device + userCode, verificationURL string + // device and qr + attempt any + hint string + expired string + // qr + image *ui.Bitmap + // token + values map[string]*string + stop chan struct{} +} + +func (f *connectFlow) end() { + if f != nil && f.stop != nil { + close(f.stop) + f.stop = nil + } +} + +func (a *app) connectorPage(c *ui.Context, id string) { + t := c.Theme() + if a.m == nil { + ui.Spinner(c) + return + } + manifest := a.m.connector(id) + if manifest == nil { + a.header(c, "/connectors", "Connectors", "Unknown connector", "", "", nil) + return + } + state := a.overviewOf(id) + if a.connect != nil && a.connect.connectorID != id { + a.connect.end() + a.connect = nil + } + var connections []ConnectionView + ready := false + if state != nil { + connections, ready = state.Connections, state.Readiness.Ready + } + canAdd := manifest.AllowsMultipleAccounts || len(connections) == 0 + + a.header(c, "/connectors", "Connectors", manifest.Name, manifest.Tagline, manifest.ID, func() { + if !ready || !canAdd { + return + } + if a.connect != nil { + if ui.Button(c, "Cancel").Clicked() { + a.connect.end() + a.connect = nil + } + return + } + label := "Connect" + switch manifest.Auth.Kind { + case "token": + label = "Add bot" + case "oauth_redirect": + if len(connections) > 0 { + label = "Add account" + } + } + if ui.PrimaryButton(c, label).Clicked() { + a.beginConnect(manifest) + } + }) + + if state != nil && !ready { + lines := []string{state.Readiness.Reason} + if manifest.Auth.Kind == "oauth_redirect" { + lines = append(lines, "Enter the app's client ID under Settings. The desktop app connects GitHub with a device code and Google with a desktop client, so no callback address is needed.") + } else if state.Readiness.FixHint != "" { + lines = append(lines, state.Readiness.FixHint) + } + alert(c, "Not set up", lines, true) + } + + if a.connect != nil { + a.connectView(c, manifest) + } + + if len(connections) > 0 { + card(c).Dividers(1, t.Border).Children(func() { + for i := range connections { + a.connectionRow(c, manifest, &connections[i]) + } + }) + } else if ready && a.connect == nil { + empty(c, "No "+a.m.connectionNoun(id)+" connected yet.", func() { + if manifest.Auth.Kind == "oauth_redirect" && manifest.Auth.Note != "" { + ui.Text(c, manifest.Auth.Note).FontSize(11).TextColor(t.TextMuted).MaxWidth(480).TextAlign(ui.Center) + } + }) + } + + // Loops that watch this connector, or write through it. + ui.Column(c).Gap(8).Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, "Loops").FontSize(13).Bold().Grow(1) + if len(manifest.Triggers) > 0 { + if ui.Button(c, "New loop").Clicked() { + a.goTo("/loops/new") + } + } + }) + var using []*LoopView + for i := range a.loops { + if a.loops[i].ConnectorID == id || a.loops[i].ActionConnectorID == id { + using = append(using, &a.loops[i]) + } + } + if len(using) == 0 { + ui.Text(c, "No loop uses "+manifest.Name+" yet.").FontSize(12).TextColor(t.TextMuted) + return + } + card(c).Dividers(1, t.Border).Children(func() { + for _, loop := range using { + loop := loop + row := ui.ButtonBase(c).Key(loop.ID).Row().Gap(12).AlignItems(ui.Center).Padding(10, 16).Label(loop.Name) + if row.Hovered() { + row.Background(t.SurfaceHover) + } + row.Children(func() { + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, loop.Name).FontSize(13).Bold().SingleLine() + text := a.m.destinationLabel(loop.ActionConnectorID, loop.ActionID) + if loop.ConnectorID != id { + text = "Answers here, from " + a.m.connectorName(loop.ConnectorID) + } + ui.Text(c, text).FontSize(11).TextColor(t.TextMuted).SingleLine() + }) + if !loop.Enabled { + ui.Badge(c, "Off") + } + ui.Icon(c, iconChevron).TextColor(t.TextMuted) + }) + if row.Clicked() { + a.goTo("/loops/" + loop.ID) + } + } + }) + }) + + ui.Row(c).Gap(24).AlignItems(ui.Start).Children(func() { + capability := func(title, emptyText string, items [][2]string) { + ui.Column(c).Grow(1).Basis(0).Gap(8).Children(func() { + ui.Text(c, title).FontSize(13).Bold() + if len(items) == 0 { + ui.Text(c, emptyText).FontSize(12).TextColor(t.TextMuted) + } + for _, item := range items { + ui.Column(c).Gap(1).Children(func() { + ui.Text(c, item[0]).FontSize(12) + ui.Text(c, item[1]).FontSize(11).TextColor(t.TextMuted) + }) + } + }) + } + var triggers, workflows, actions [][2]string + for _, trigger := range manifest.Triggers { + triggers = append(triggers, [2]string{trigger.When, "When " + trigger.Trigger + "."}) + } + for _, workflow := range manifest.Workflows { + workflows = append(workflows, [2]string{workflow.Name, workflow.Summary}) + } + for _, action := range manifest.Actions { + actions = append(actions, [2]string{action.Name, action.Summary}) + } + capability("Starts when", "Nothing. This connector cannot start a loop.", triggers) + capability("Workflows", "None. Use a custom loop.", workflows) + capability("Send to", "Nothing. This connector only reads.", actions) + }) +} + +func (a *app) beginConnect(manifest *Connector) { + flow := &connectFlow{connectorID: manifest.ID, stop: make(chan struct{})} + a.connect = flow + switch manifest.Auth.Kind { + case "token": + flow.kind = "token" + flow.values = map[string]*string{} + for _, field := range manifest.Auth.Fields { + s := "" + flow.values[field.Key] = &s + } + case "qr_scan": + flow.kind = "qr" + a.startQr(flow) + default: + flow.loading = true + go func() { + var started struct { + Kind string `json:"kind"` + URL string `json:"url"` + Attempt any `json:"attempt"` + UserCode string `json:"userCode"` + VerificationURL string `json:"verificationUrl"` + IntervalMs int `json:"intervalMs"` + } + err := a.call("beginConnect", map[string]string{"connectorId": manifest.ID}, &started) + a.update(func() { + if a.connect != flow { + return + } + flow.loading = false + if err != nil { + a.toast(errText(err)) + a.connect = nil + return + } + flow.kind = started.Kind + if started.Kind == "browser" { + _ = mygo.Shell.OpenExternal(started.URL) + flow.hint = "Finish in your browser. This page updates when the account is connected." + go a.watchConnections(flow, manifest.ID) + return + } + flow.userCode, flow.verificationURL, flow.attempt = started.UserCode, started.VerificationURL, started.Attempt + interval := time.Duration(started.IntervalMs) * time.Millisecond + if interval < time.Second { + interval = 5 * time.Second + } + _ = mygo.Shell.OpenExternal(started.VerificationURL) + go a.pollConnect(flow, "continueDeviceLogin", manifest.ID, interval) + }) + }() + } +} + +func (a *app) startQr(flow *connectFlow) { + flow.loading = true + flow.expired, flow.hint, flow.image = "", "", nil + go func() { + var started struct { + Attempt any `json:"attempt"` + Image string `json:"image"` + } + err := a.call("beginQrLogin", map[string]string{"connectorId": flow.connectorID}, &started) + var bitmap *ui.Bitmap + if err == nil { + if data, decodeErr := base64.StdEncoding.DecodeString(started.Image); decodeErr == nil { + bitmap, _ = ui.DecodeBitmap(data) + } + } + a.update(func() { + if a.connect != flow { + return + } + flow.loading = false + if err != nil { + flow.expired = errText(err) + return + } + flow.attempt, flow.image = started.Attempt, bitmap + go a.pollConnect(flow, "continueQrLogin", flow.connectorID, 2*time.Second) + }) + }() +} + +// pollConnect asks every so often whether the code was used, until it +// was, it expired, or the flow was cancelled. +func (a *app) pollConnect(flow *connectFlow, method, connectorID string, every time.Duration) { + stop := flow.stop + for { + select { + case <-stop: + return + case <-time.After(every): + } + var attempt any + done := make(chan bool, 1) + a.update(func() { + attempt = flow.attempt + done <- a.connect == flow && flow.expired == "" + }) + if !<-done { + return + } + var outcome struct { + State string `json:"state"` + Attempt any `json:"attempt"` + Hint string `json:"hint"` + Reason string `json:"reason"` + AccountLabel string `json:"accountLabel"` + } + err := a.call(method, map[string]any{"connectorId": connectorID, "attempt": attempt}, &outcome) + finished := false + a.update(func() { + if a.connect != flow { + finished = true + return + } + if err != nil { + flow.hint = errText(err) + return + } + switch outcome.State { + case "connected": + a.toast("Connected " + outcome.AccountLabel) + a.connect = nil + finished = true + a.refresh() + case "expired": + flow.expired = outcome.Reason + if flow.expired == "" { + flow.expired = "The code expired before it was used." + } + finished = true + default: + flow.attempt, flow.hint = outcome.Attempt, outcome.Hint + } + }) + if finished { + return + } + } +} + +// watchConnections waits for a browser authorization to land, for a few +// minutes, by looking at the connections. +func (a *app) watchConnections(flow *connectFlow, connectorID string) { + stop := flow.stop + before := -1 + deadline := time.Now().Add(5 * time.Minute) + for time.Now().Before(deadline) { + select { + case <-stop: + return + case <-time.After(2 * time.Second): + } + var overview []ConnectorOverview + if a.call("getConnectorOverview", nil, &overview) != nil { + continue + } + count := 0 + for _, entry := range overview { + if entry.ConnectorID == connectorID { + count = len(entry.Connections) + } + } + if before < 0 { + before = count + continue + } + if count > before { + a.update(func() { + if a.connect == flow { + a.connect = nil + a.toast("Connected") + a.refresh() + } + }) + return + } + } +} + +func (a *app) connectView(c *ui.Context, manifest *Connector) { + t := c.Theme() + flow := a.connect + card(c).Padding(16).Gap(12).Children(func() { + switch flow.kind { + case "token": + if manifest.Auth.Note != "" { + ui.Text(c, manifest.Auth.Note).FontSize(12).TextColor(t.TextMuted) + } + if manifest.Auth.HelpURL != "" { + ui.Link(c, "Open the app dashboard", manifest.Auth.HelpURL).FontSize(12) + } + ui.Form(c, func() { + for _, field := range manifest.Auth.Fields { + field := field + label := field.Label + if !field.Optional { + label += " *" + } + ui.Field(c, label, func() { + input := ui.TextInput(c, flow.values[field.Key]).Placeholder(field.Placeholder).MaxWidth(480) + if field.Secret { + input.Password() + } + }) + } + }) + ui.Row(c).Children(func() { + label := "Connect" + if flow.loading { + label = "Connecting…" + } + if ui.PrimaryButton(c, label).Disabled(flow.loading).Clicked() { + fields := map[string]string{} + for key, value := range flow.values { + fields[key] = strings.TrimSpace(*value) + } + flow.loading = true + go func() { + var result struct { + AccountLabel string `json:"accountLabel"` + } + err := a.call("connectWithToken", map[string]any{"connectorId": manifest.ID, "fields": fields}, &result) + a.update(func() { + flow.loading = false + if err != nil { + a.toast(errText(err)) + return + } + a.toast("Connected " + result.AccountLabel) + if a.connect == flow { + a.connect = nil + } + a.refresh() + }) + }() + } + }) + case "device": + ui.Text(c, "Enter this code on GitHub to connect the account Loopable should watch and answer as.").FontSize(12).TextColor(t.TextMuted) + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, flow.userCode).Font("monospace").FontSize(26).Bold().Selectable().Padding(8, 14).Radius(8).Background(t.Background).Border(1, t.Border) + if ui.Button(c, "Copy code").Clicked() { + mygo.Clipboard.WriteText(flow.userCode) + c.Toast("Copied") + } + if ui.PrimaryButton(c, "Open GitHub").Clicked() { + _ = mygo.Shell.OpenExternal(flow.verificationURL) + } + }) + a.connectStatus(c, flow) + case "qr": + if manifest.Auth.Note != "" { + ui.Text(c, manifest.Auth.Note).FontSize(12).TextColor(t.TextMuted) + } + ui.Box(c).Size(240, 240).Radius(8).Background(ui.Hex("#ffffff")).Border(1, t.Border).Center().AlignSelf(ui.Center).Children(func() { + if flow.image != nil && flow.expired == "" { + ui.Image(c, flow.image).Size(232, 232) + } else if flow.expired == "" { + ui.Spinner(c) + } + }) + a.connectStatus(c, flow) + ui.Row(c).Justify(ui.Center).Children(func() { + label := "Start over" + if flow.expired != "" { + label = "New code" + } + if ui.Button(c, label).Disabled(flow.loading).Clicked() { + a.startQr(flow) + } + }) + default: + ui.Row(c).Gap(10).AlignItems(ui.Center).Children(func() { + ui.Spinner(c) + text := flow.hint + if text == "" { + text = "Starting…" + } + ui.Text(c, text).FontSize(12).TextColor(t.TextMuted) + }) + } + }) +} + +func (a *app) connectStatus(c *ui.Context, flow *connectFlow) { + t := c.Theme() + if flow.expired != "" { + alert(c, "Not connected", []string{flow.expired}, true) + return + } + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Spinner(c) + text := flow.hint + if text == "" { + text = "Waiting…" + } + ui.Text(c, text).FontSize(12).TextColor(t.TextMuted) + }) +} + +func (a *app) connectionRow(c *ui.Context, manifest *Connector, connection *ConnectionView) { + t := c.Theme() + busy := a.busy["connection:"+connection.ID] + broken := connection.Status == "needs_reauth" || connection.Status == "error" + ui.Column(c).Key(connection.ID).Gap(12).Padding(12, 16).Children(func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Avatar(c, connection.AccountLabel, nil).Size(32, 32) + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, connection.AccountLabel).FontSize(13).Bold().SingleLine() + statusBadge(c, connection.Status) + }) + parts := []string{"Not checked yet"} + if connection.LastSyncedAt != nil { + parts[0] = "Checked " + ago(*connection.LastSyncedAt) + } + if len(connection.Scopes) > 0 { + parts = append(parts, strings.Join(connection.Scopes, ", ")) + } + ui.Text(c, strings.Join(parts, " · ")).FontSize(11).TextColor(t.TextMuted).SingleLine() + }) + if broken && manifest.Auth.Kind == "oauth_redirect" { + if ui.PrimaryButton(c, "Reconnect").Disabled(busy || a.connect != nil).Clicked() { + a.beginConnect(manifest) + } + } + if ui.Button(c, "Verify").Disabled(busy).Clicked() { + id := connection.ID + a.run("connection:"+id, func() (string, error) { + var updated ConnectionView + if err := a.call("verifyConnection", map[string]string{"id": id}, &updated); err != nil { + return "", err + } + if updated.Status == "connected" { + return updated.AccountLabel + " is reachable", nil + } + if updated.LastError != nil { + return "", errorString(*updated.LastError) + } + return "", errorString("This connection is not usable") + }) + } + if ui.Button(c, "Disconnect").Disabled(busy).Clicked() { + a.confirm = confirmState{ + open: true, kind: "connection", id: connection.ID, name: connection.AccountLabel, + title: "Disconnect " + connection.AccountLabel + "?", body: "Loops that use it stop until another is connected.", + } + } + }) + if connection.LastError != nil && *connection.LastError != "" { + ui.Text(c, *connection.LastError).FontSize(11).TextColor(t.Danger).Selectable() + } + if len(manifest.Settings) > 0 { + key := "connection:" + connection.ID + values := a.fields[key] + if values == nil { + values = newFieldValues(manifest.Settings, connection.Settings) + a.fields[key] = values + } + ui.Form(c, func() { values.render(c) }) + ui.Row(c).Children(func() { + if ui.Button(c, "Save settings").Disabled(busy).Clicked() { + id := connection.ID + settings := values.values() + a.run("connection:"+id, func() (string, error) { + if err := a.call("saveConnectionSettings", map[string]any{"id": id, "settings": settings}, nil); err != nil { + return "", err + } + return "Settings saved", nil + }) + } + }) + } + }) +} + +type errorString string + +func (e errorString) Error() string { return string(e) } diff --git a/desktop/engine.go b/desktop/engine.go new file mode 100644 index 0000000..6a62c90 --- /dev/null +++ b/desktop/engine.go @@ -0,0 +1,358 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/egoist/mygo" +) + +// Engine is the Node process that does everything: the dispatcher, the +// runner and the JSON API the screens call. The app starts it, keeps it +// running, and stops it on quit. +type Engine struct { + mu sync.Mutex + cmd *exec.Cmd + stdin io.WriteCloser + port int + token string + state string // starting, running, stopped, failed + detail string // why, when failed + node nodeInfo + lines []string + stopping bool + restarts int + onChange func() + client *http.Client +} + +// OAuthApps is what the user registered with the providers, kept in +// oauth.json in the app's data folder. Client IDs only; the desktop app is +// a public client. +type OAuthApps struct { + GitHub struct { + ClientID string `json:"clientId"` + } `json:"github"` + Gmail struct { + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + } `json:"gmail"` +} + +func newEngine(onChange func()) *Engine { + b := make([]byte, 24) + _, _ = rand.Read(b) + return &Engine{ + token: hex.EncodeToString(b), + state: "starting", + onChange: onChange, + client: &http.Client{Timeout: 10 * time.Minute}, + } +} + +func userDataDir() string { + dir, err := mygo.App.Path(mygo.PathUserData) + if err != nil || dir == "" { + home, _ := os.UserHomeDir() + dir = filepath.Join(home, "Library", "Application Support", "Loopable") + } + _ = os.MkdirAll(dir, 0o755) + return dir +} + +func oauthFile() string { return filepath.Join(userDataDir(), "oauth.json") } + +func readOAuthApps() OAuthApps { + var apps OAuthApps + data, err := os.ReadFile(oauthFile()) + if err == nil { + _ = json.Unmarshal(data, &apps) + } + return apps +} + +func writeOAuthApps(apps OAuthApps) error { + data, err := json.MarshalIndent(apps, "", " ") + if err != nil { + return err + } + return os.WriteFile(oauthFile(), data, 0o600) +} + +// engineDir is where engine.mjs lives: beside the app's resources, or +// wherever LOOPABLE_ENGINE_DIR points while developing. +func engineDir() string { + if dir := os.Getenv("LOOPABLE_ENGINE_DIR"); dir != "" { + return dir + } + if dir, err := mygo.App.Path(mygo.PathResources); err == nil { + return filepath.Join(dir, "engine") + } + return "resources/engine" +} + +func (e *Engine) snapshot() (state, detail string, port int, node nodeInfo, lines []string) { + e.mu.Lock() + defer e.mu.Unlock() + return e.state, e.detail, e.port, e.node, append([]string(nil), e.lines...) +} + +func (e *Engine) set(state, detail string) { + if debug { + fmt.Fprintln(os.Stderr, "engine state:", state, detail) + } + e.mu.Lock() + e.state, e.detail = state, detail + e.mu.Unlock() + if e.onChange != nil { + e.onChange() + } +} + +func (e *Engine) log(line string) { + if debug { + fmt.Fprintln(os.Stderr, "engine:", line) + } + e.mu.Lock() + e.lines = append(e.lines, line) + if len(e.lines) > 300 { + e.lines = e.lines[len(e.lines)-300:] + } + e.mu.Unlock() +} + +// Start runs the engine, and runs it again when it dies, until Stop. +func (e *Engine) Start() { + go func() { + for { + e.mu.Lock() + stopping := e.stopping + e.mu.Unlock() + if stopping { + return + } + err := e.runOnce() + e.mu.Lock() + stopping = e.stopping + e.mu.Unlock() + if stopping { + e.set("stopped", "") + return + } + if err != nil { + var fatal fatalError + if errors.As(err, &fatal) { + e.set("failed", err.Error()) + return + } + e.log("engine: " + err.Error()) + } + e.restarts++ + wait := time.Duration(min(e.restarts, 5)) * 2 * time.Second + e.set("starting", fmt.Sprintf("Starting again in %s…", wait)) + time.Sleep(wait) + } + }() +} + +type fatalError struct{ error } + +func (e *Engine) runOnce() error { + dir := engineDir() + script := filepath.Join(dir, "engine.mjs") + if _, err := os.Stat(script); err != nil { + return fatalError{fmt.Errorf("The engine is missing at %s.", script)} + } + path := enginePath() + node, err := findNode(path) + if err != nil { + return fatalError{err} + } + e.mu.Lock() + e.node = node + e.mu.Unlock() + + apps := readOAuthApps() + env := []string{ + "PATH=" + path, + "HOME=" + os.Getenv("HOME"), + "USER=" + os.Getenv("USER"), + "SHELL=" + os.Getenv("SHELL"), + "LANG=" + os.Getenv("LANG"), + "TMPDIR=" + os.Getenv("TMPDIR"), + "LOOPABLE_DESKTOP=1", + "LOOPABLE_DESKTOP_TOKEN=" + e.token, + "LOOPABLE_HOME=" + filepath.Join(userDataDir(), "data"), + "LOOPABLE_SECRET_PREFIX=loopable-desktop", + "LOOPABLE_GITHUB_CLIENT_ID=" + apps.GitHub.ClientID, + "LOOPABLE_GMAIL_CLIENT_ID=" + apps.Gmail.ClientID, + "LOOPABLE_GMAIL_CLIENT_SECRET=" + apps.Gmail.ClientSecret, + } + cmd := exec.Command(node.Path, script) + cmd.Dir = dir + cmd.Env = env + stdin, err := cmd.StdinPipe() + if err != nil { + return err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return fatalError{fmt.Errorf("Node could not start: %v", err)} + } + e.mu.Lock() + e.cmd, e.stdin, e.port = cmd, stdin, 0 + e.mu.Unlock() + e.set("starting", "Starting the engine…") + + go func() { + scanner := bufio.NewScanner(stderr) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + for scanner.Scan() { + e.log(scanner.Text()) + } + }() + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + var event struct { + Event string `json:"event"` + Port int `json:"port"` + Owner string `json:"owner"` + } + if json.Unmarshal(scanner.Bytes(), &event) != nil { + continue + } + switch event.Event { + case "listening": + e.mu.Lock() + e.port = event.Port + e.restarts = 0 + e.mu.Unlock() + e.set("running", "") + case "busy": + e.log("another Loopable is using this data folder: " + event.Owner) + } + } + err = cmd.Wait() + e.mu.Lock() + e.cmd, e.stdin, e.port = nil, nil, 0 + e.mu.Unlock() + if err != nil { + return fmt.Errorf("the engine stopped: %v", err) + } + return errors.New("the engine stopped") +} + +// Stop ends the engine, for quitting; Restart ends it and lets Start's +// loop bring it back. +func (e *Engine) Stop() { + e.mu.Lock() + e.stopping = true + cmd, stdin := e.cmd, e.stdin + e.mu.Unlock() + e.kill(cmd, stdin) +} + +func (e *Engine) Restart() { + e.mu.Lock() + cmd, stdin := e.cmd, e.stdin + e.restarts = 0 + e.mu.Unlock() + e.set("starting", "Restarting…") + e.kill(cmd, stdin) +} + +func (e *Engine) kill(cmd *exec.Cmd, stdin io.WriteCloser) { + if cmd == nil || cmd.Process == nil { + return + } + if stdin != nil { + _ = stdin.Close() + } + _ = cmd.Process.Signal(os.Interrupt) + done := make(chan struct{}) + go func() { + _, _ = cmd.Process.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(8 * time.Second): + _ = cmd.Process.Kill() + } +} + +// debug echoes the engine to this process's stderr, for a terminal run. +var debug = os.Getenv("LOOPABLE_DEBUG") == "1" + +var errNotRunning = errors.New("The engine is not running yet.") + +// Call asks the engine for one thing, by the name it is known by in +// src/desktop/api.ts, and reads the answer into out. +func (e *Engine) Call(name string, in any, out any) error { + return e.CallContext(context.Background(), name, in, out) +} + +func (e *Engine) CallContext(ctx context.Context, name string, in any, out any) error { + e.mu.Lock() + port, token := e.port, e.token + e.mu.Unlock() + if port == 0 { + return errNotRunning + } + body, err := json.Marshal(in) + if err != nil { + return err + } + if in == nil { + body = []byte("null") + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/rpc/%s", port, name), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + res, err := e.client.Do(req) + if err != nil { + return errors.New("The engine did not answer: " + err.Error()) + } + defer res.Body.Close() + data, err := io.ReadAll(res.Body) + if err != nil { + return err + } + if res.StatusCode != http.StatusOK { + var failure struct { + Error string `json:"error"` + } + if json.Unmarshal(data, &failure) == nil && failure.Error != "" { + return errors.New(failure.Error) + } + return fmt.Errorf("%s: %s", name, strings.TrimSpace(string(data))) + } + if out == nil || string(data) == "null" { + return nil + } + return json.Unmarshal(data, out) +} diff --git a/desktop/engine_test.go b/desktop/engine_test.go new file mode 100644 index 0000000..d67be8d --- /dev/null +++ b/desktop/engine_test.go @@ -0,0 +1,186 @@ +package main + +import ( + "bufio" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +// A real engine in a throwaway data folder, for the screens to load from. +// Skipped where there is no Node or no built engine. +func startTestEngine(t *testing.T) *Engine { + t.Helper() + dir, _ := filepath.Abs("resources/engine") + if _, err := os.Stat(filepath.Join(dir, "engine.mjs")); err != nil { + t.Skip("no engine built; run node scripts/build-engine.mjs") + } + node, err := findNode(enginePath()) + if err != nil { + t.Skip(err) + } + e := newEngine(nil) + home := t.TempDir() + cmd := exec.Command(node.Path, filepath.Join(dir, "engine.mjs")) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "LOOPABLE_DESKTOP=1", "LOOPABLE_DESKTOP_TOKEN="+e.token, "LOOPABLE_HOME="+home, + "LOOPABLE_KEYCHAIN=0", "LOOPABLE_SECRET_PREFIX=loopable-desktop-test", + ) + stdin, _ := cmd.StdinPipe() + stdout, _ := cmd.StdoutPipe() + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = stdin.Close() + _ = cmd.Process.Signal(os.Interrupt) + _, _ = cmd.Process.Wait() + }) + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + var event struct { + Event string `json:"event"` + Port int `json:"port"` + } + if json.Unmarshal(scanner.Bytes(), &event) == nil && event.Event == "listening" { + e.port = event.Port + e.state = "running" + break + } + } + if e.port == 0 { + t.Fatal("the engine did not start") + } + return e +} + +func TestScreensWithARealEngine(t *testing.T) { + a := newApp() + a.engine = startTestEngine(t) + a.settings.loaded = true + + load := func(path string) { + a.router.Push(path) + a.enter(path) + a.refresh() + deadline := time.Now().Add(30 * time.Second) + for a.refreshing.Load() && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + } + load("/inbox") + if a.m == nil { + t.Fatal("the manifests did not load") + } + if len(a.m.Connectors) < 5 || len(a.m.Agents) < 3 { + t.Fatalf("manifests: %d connectors, %d agents", len(a.m.Connectors), len(a.m.Agents)) + } + if a.m.workflow("github", "github.review") == nil && len(a.m.connector("github").Workflows) == 0 { + t.Fatalf("GitHub should offer workflows") + } + + // Make a loop through the form, as a person would, on the clock, which + // needs no account. + load("/loops/new") + tt := ui.NewTester(a.view, 1040, 800) + shots := os.Getenv("LOOPABLE_SHOTS") + if !tt.HasText("Custom loop") { + t.Fatalf("the new loop page should offer a custom loop: %q", tt.Texts()) + } + schedule := a.m.connector("schedule") + if schedule == nil || len(schedule.Workflows) == 0 { + t.Fatal("the clock should offer a workflow") + } + workflow := schedule.Workflows[0] + load("/loops/new/schedule/" + workflow.ID) + tt = ui.NewTester(a.view, 1040, 900) + form := a.forms["new:schedule/"+workflow.ID] + if form == nil { + t.Fatal("no form") + } + form.name = "Test loop from the desktop" + form.folder = "code/web" + a.saveLoop(form, a.m.workflow("schedule", workflow.ID)) + deadline := time.Now().Add(20 * time.Second) + for form.saving && time.Now().Before(deadline) { + time.Sleep(20 * time.Millisecond) + } + if form.saving { + t.Fatal("saving did not finish") + } + load(a.router.Path()) + if a.loopPage.loop == nil || a.loopPage.loop.Name != "Test loop from the desktop" { + t.Fatalf("the saved loop should be open: %+v at %s (toasts %q)", a.loopPage.loop, a.router.Path(), a.toasts) + } + tt = ui.NewTester(a.view, 1040, 800) + for range 40 { + tt.Frame() + } + if !tt.HasText("Test loop from the desktop") { + t.Fatalf("the loop page should show the loop: %q", tt.Texts()) + } + save(t, tt, shots, "live-loop") + + load("/loops") + tt = ui.NewTester(a.view, 1040, 700) + for range 40 { + tt.Frame() + } + if !tt.HasText("Test loop from the desktop") { + t.Fatalf("the loops page should list the loop: %q", tt.Texts()) + } + save(t, tt, shots, "live-loops") + + load("/agents") + tt = ui.NewTester(a.view, 1040, 700) + for range 40 { + tt.Frame() + } + if len(a.agents) < 3 { + t.Fatalf("agents: %d", len(a.agents)) + } + save(t, tt, shots, "live-agents") + + load("/connectors") + tt = ui.NewTester(a.view, 1040, 700) + for range 40 { + tt.Frame() + } + if !tt.HasText("GitHub") || !tt.HasText("Slack") { + t.Fatalf("connectors: %q", tt.Texts()) + } + save(t, tt, shots, "live-connectors") + + load("/settings") + tt = ui.NewTester(a.view, 1040, 760) + for range 40 { + tt.Frame() + } + if a.status == nil || len(a.status.Runners) != 1 { + t.Fatalf("status: %+v", a.status) + } + save(t, tt, shots, "live-settings") +} + +func save(t *testing.T, tt *ui.Tester, dir, name string) { + t.Helper() + if dir == "" { + return + } + _ = os.MkdirAll(dir, 0o755) + f, err := os.Create(filepath.Join(dir, name+".png")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := encodePNG(f, tt.Image()); err != nil { + t.Fatal(err) + } +} diff --git a/desktop/go.mod b/desktop/go.mod new file mode 100644 index 0000000..f76dd7f --- /dev/null +++ b/desktop/go.mod @@ -0,0 +1,13 @@ +module desktop + +go 1.27.2 + +tool github.com/egoist/mygo/cmd/mygo + +require github.com/egoist/mygo v0.4.8 + +require ( + github.com/ebitengine/purego v0.11.1 // indirect + github.com/go-text/typesetting v0.3.5 // indirect + golang.org/x/image v0.46.0 // indirect +) diff --git a/desktop/go.sum b/desktop/go.sum new file mode 100644 index 0000000..4b19db0 --- /dev/null +++ b/desktop/go.sum @@ -0,0 +1,10 @@ +github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= +github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= +github.com/egoist/mygo v0.4.8 h1:NpzFFiv8L4uXhkriknAehoucN//gtCtnGgr/DzQxZIw= +github.com/egoist/mygo v0.4.8/go.mod h1:+B7ZTBwVfa/nL/6+o6Vm8xBFYwJoFCg4VJbY9k6T0E8= +github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk= +github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68= +github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY= +github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= diff --git a/desktop/icons.go b/desktop/icons.go new file mode 100644 index 0000000..94ddb78 --- /dev/null +++ b/desktop/icons.go @@ -0,0 +1,106 @@ +package main + +import ( + "bytes" + "image" + "image/color" + "image/draw" + "image/png" + "runtime" + + "github.com/egoist/mygo/ui" +) + +// Line icons in the style of Lucide, drawn with the text color. +func icon(paths string) *ui.SVG { + return ui.MustParseSVG([]byte(`` + paths + ``)) +} + +var ( + iconInbox = icon(``) + iconLoops = icon(``) + iconConnectors = icon(``) + iconAgents = icon(``) + iconSettings = icon(``) + iconPlay = icon(``) + iconMore = icon(``) + iconCheck = icon(``) + iconX = icon(``) + iconMinus = icon(``) + iconClock = icon(``) + iconFile = icon(``) + iconBot = iconAgents + iconAlert = icon(``) + iconPencil = icon(``) + iconRefresh = icon(``) + iconStop = icon(``) + iconOpen = icon(``) + iconPlus = icon(``) + iconChevron = icon(``) +) + +// A connector shows as a tile with a letter or two in its color, since the +// web app's icons are a font the window has not got. +type tile struct { + text string + hue ui.Color +} + +var tiles = map[string]tile{ + "github": {"GH", ui.Hex("#24292f")}, + "slack": {"Sl", ui.Hex("#4a154b")}, + "feishu": {"Fs", ui.Hex("#3370ff")}, + "gmail": {"Gm", ui.Hex("#c5221f")}, + "wechat": {"Wx", ui.Hex("#07c160")}, + "schedule": {"Sc", ui.Hex("#334155")}, + "codex": {"Cx", ui.Hex("#0f172a")}, + "claude": {"Cl", ui.Hex("#d97757")}, +} + +func tileFor(id string) tile { + if t, ok := tiles[id]; ok { + return t + } + label := id + if len(label) > 2 { + label = label[:2] + } + return tile{label, ui.Hex("#64748b")} +} + +// connectorTile draws the tile, size DIPs square. +func connectorTile(c *ui.Context, id string, size float32, dim bool) ui.Element { + t := tileFor(id) + box := ui.Box(c).Size(size, size).Radius(size / 4).Background(t.hue).Center().Shrink(0) + if dim { + box.Opacity(0.5) + } + box.Children(func() { + ui.Text(c, t.text).FontSize(size * 0.4).Bold().TextColor(ui.Hex("#ffffff")) + }) + return box +} + +// trayIcon draws a loop: black on transparent, which macOS tints. +func trayIcon() []byte { + img := image.NewNRGBA(image.Rect(0, 0, 44, 44)) + ink := color.NRGBA{R: 79, G: 124, B: 255, A: 255} + if runtime.GOOS == "darwin" { + ink = color.NRGBA{A: 255} + } + // A ring with a gap, and an arrow head: a loop. + for y := 0; y < 44; y++ { + for x := 0; x < 44; x++ { + dx, dy := float64(x)-22, float64(y)-22 + d := dx*dx + dy*dy + if d > 11*11 && d < 17*17 && !(dx > 0 && dy < 0 && dy > -8) { + img.Set(x, y, ink) + } + } + } + draw.Draw(img, image.Rect(28, 6, 40, 10), image.NewUniform(ink), image.Point{}, draw.Src) + draw.Draw(img, image.Rect(36, 6, 40, 18), image.NewUniform(ink), image.Point{}, draw.Src) + var b bytes.Buffer + _ = png.Encode(&b, img) + return b.Bytes() +} diff --git a/desktop/inbox.go b/desktop/inbox.go new file mode 100644 index 0000000..e85aa66 --- /dev/null +++ b/desktop/inbox.go @@ -0,0 +1,316 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" +) + +var kindLabel = map[string]string{ + "pull_request": "Pull request", "issue": "Issue", "message": "Message", + "email": "Email", "occurrence": "Scheduled run", "prompt": "Test run", +} + +type stateMeta struct { + label string + icon *ui.SVG + tone string // muted, success, danger + spins bool +} + +var states = map[string]stateMeta{ + "queued": {"Queued", iconClock, "muted", false}, + "preparing": {"Working", nil, "muted", true}, + "awaiting_agent": {"Running", nil, "muted", true}, + "applying": {"Writing", nil, "muted", true}, + "prepared": {"Prepared", iconFile, "muted", false}, + "done": {"Written", iconCheck, "success", false}, + "skipped": {"Nothing to say", iconMinus, "muted", false}, + "failed": {"Failed", iconX, "danger", false}, + "cancelled": {"Stopped", iconStop, "muted", false}, +} + +func stateLabel(c *ui.Context, state string) { + t := c.Theme() + meta, ok := states[state] + if !ok { + meta = stateMeta{label: state, tone: "muted"} + } + color := t.TextMuted + switch meta.tone { + case "success": + color = t.Success + case "danger": + color = t.Danger + } + ui.Row(c).Gap(5).AlignItems(ui.Center).Shrink(0).Children(func() { + if meta.spins { + ui.Spinner(c).FontSize(12).TextColor(color) + } else if meta.icon != nil { + ui.Icon(c, meta.icon).FontSize(13).TextColor(color) + } + ui.Text(c, meta.label).FontSize(12).TextColor(color) + }) +} + +func (a *app) inboxPage(c *ui.Context) { + a.header(c, "", "", "Inbox", "Every run, newest first.", "", func() { + if ui.Button(c, "Ask an agent").Clicked() { + a.ask.open = true + } + }) + a.taskList(c, a.tasks, true, "Nothing has run yet.") + a.askDialog(c) +} + +func (a *app) taskList(c *ui.Context, tasks []TaskView, showLoop bool, emptyText string) { + t := c.Theme() + if len(tasks) == 0 { + empty(c, emptyText, nil) + return + } + card(c).Dividers(1, t.Border).Children(func() { + for i := range tasks { + task := &tasks[i] + row := ui.ButtonBase(c).Key(task.ID).Row().Gap(12).AlignItems(ui.Start).Padding(12, 16).Label(task.Title()) + if row.Hovered() { + row.Background(t.SurfaceHover) + } + row.Children(func() { + if task.SourceKind == "prompt" { + ui.Box(c).Size(28, 28).Radius(7).Background(t.Background).Border(1, t.Border).Center().Shrink(0).Children(func() { + ui.Icon(c, iconBot).FontSize(15).TextColor(t.TextMuted) + }) + } else { + connectorTile(c, task.ConnectorID, 28, false) + } + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, task.Title()).FontSize(13).Bold().Grow(1).SingleLine() + if task.DryRun { + ui.Badge(c, "Dry run") + } + stateLabel(c, task.State) + }) + ui.Text(c, a.taskMeta(task, showLoop)).FontSize(11).TextColor(t.TextMuted).SingleLine() + if task.Output != nil && *task.Output != "" { + ui.Text(c, firstLine(*task.Output)).FontSize(11).TextColor(t.TextMuted).SingleLine() + } else if task.Error != nil && *task.Error != "" { + ui.Text(c, firstLine(*task.Error)).FontSize(11).TextColor(t.Danger).SingleLine() + } + }) + }) + if row.Clicked() { + a.goTo("/inbox/" + task.ID) + } + } + }) +} + +func (a *app) taskMeta(task *TaskView, showLoop bool) string { + var parts []string + if (task.SourceKind == "pull_request" || task.SourceKind == "issue") && task.SourceTitle != nil { + parts = append(parts, task.SourceRef) + } + if showLoop && task.LoopName != "" { + parts = append(parts, task.LoopName) + } + if task.AgentID != nil { + parts = append(parts, a.m.agentName(*task.AgentID)) + } + parts = append(parts, ago(task.CreatedAt)) + return strings.Join(parts, " · ") +} + +// askDialog asks an agent something once. The reply stays in Inbox. +func (a *app) askDialog(c *ui.Context) { + t := c.Theme() + ui.Modal(c, &a.ask.open, func() { + ui.Column(c).Gap(12).Width(520).Children(func() { + ui.Text(c, "Ask an agent").FontSize(16).Bold() + ui.Text(c, "Runs once. The reply stays in Inbox.").FontSize(12).TextColor(t.TextMuted) + ui.TextArea(c, &a.ask.prompt).Lines(5, 12).Placeholder("What should the agent do?").Label("Prompt").AutoFocus() + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + if len(a.runnable) == 0 { + ui.Text(c, "No agent is signed in.").FontSize(12).TextColor(t.TextMuted) + } else { + names := make([]string, len(a.runnable)) + for i, agent := range a.runnable { + names[i] = agent.Name + } + if !contains(names, a.ask.agent) { + a.ask.agent = names[0] + } + ui.Select(c, &a.ask.agent, names).Label("Agent") + } + ui.Spacer(c) + if ui.Button(c, "Cancel").Clicked() { + a.ask.open = false + } + can := strings.TrimSpace(a.ask.prompt) != "" && len(a.runnable) > 0 && !a.ask.running + if ui.PrimaryButton(c, "Run").Disabled(!can).Clicked() || (can && c.Shortcut(ui.Cmd, ui.KeyEnter)) { + a.askRun() + } + }) + }) + }) +} + +func (a *app) askRun() { + agentID := "" + for _, agent := range a.runnable { + if agent.Name == a.ask.agent { + agentID = agent.AgentID + } + } + prompt := a.ask.prompt + a.ask.running = true + go func() { + err := a.call("runPrompt", map[string]string{"prompt": prompt, "agentId": agentID}, nil) + a.update(func() { + a.ask.running = false + if err != nil { + a.toast(errText(err)) + return + } + a.ask.prompt = "" + a.ask.open = false + a.toast("Queued") + a.refresh() + }) + }() +} + +func (a *app) taskPage(c *ui.Context, id string) { + t := c.Theme() + task := a.task.task + if task == nil || task.ID != id { + a.header(c, "/inbox", "Inbox", "Run", "", "", nil) + ui.Spinner(c) + return + } + live := task.Active() + a.header(c, "/inbox", "Inbox", task.Title(), a.taskMeta(task, true), "", func() { + if task.DryRun { + ui.Badge(c, "Dry run") + } + stateLabel(c, task.State) + if live { + asked := task.CancelRequested || a.busy["stop:"+task.ID] + label := "Stop" + if asked { + label = "Stopping" + } + if ui.Button(c, label).Disabled(asked).Clicked() { + a.run("stop:"+task.ID, func() (string, error) { + return "", a.call("stopTask", map[string]string{"id": task.ID}, nil) + }) + } + } + }) + if task.SourceURL != "" && task.SourceKind != "prompt" { + ui.Link(c, task.SourceRef, task.SourceURL).FontSize(12) + } + if task.LoopID != nil { + ui.Link(c, task.LoopName, "/loops/"+*task.LoopID).FontSize(12) + } + + if task.Error != nil && *task.Error != "" { + title := "Will try again" + switch task.State { + case "failed": + title = "Failed" + case "cancelled": + title = "Stopped" + } + alert(c, title, []string{*task.Error}, task.State == "failed") + } + + if task.Output != nil && *task.Output != "" { + card(c).Padding(16).Gap(12).Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + title := "Result" + switch task.State { + case "done": + title = "Written" + case "skipped": + title = "Nothing to write" + } + ui.Text(c, title).FontSize(14).Bold().Grow(1) + if task.ResultURL != nil && *task.ResultURL != "" { + if ui.Button(c, "Open in "+a.m.writerName(task.ActionConnectorID)).Clicked() { + _ = mygo.Shell.OpenExternal(*task.ResultURL) + } + } + }) + ui.Text(c, *task.Output).FontSize(13).LineHeight(1.5).Selectable() + if len(task.Comments) > 0 { + ui.Divider(c) + ui.Textf(c, "%d line comments", len(task.Comments)).FontSize(11).TextColor(t.TextMuted) + for _, comment := range task.Comments { + where := fmt.Sprintf("%s:%d", comment.Path, comment.Line) + if comment.StartLine != nil { + where = fmt.Sprintf("%s:%d-%d", comment.Path, *comment.StartLine, comment.Line) + } + ui.Column(c).Gap(4).Children(func() { + ui.Text(c, where).Font("monospace").FontSize(11).TextColor(t.TextMuted) + ui.Text(c, comment.Body).FontSize(13).Selectable().Padding(0, 0, 0, 10).BorderWidth(0, 0, 0, 2).BorderColor(t.Border) + }) + } + } + }) + } else if task.State == "skipped" { + ui.Text(c, "The agent had nothing to say, so nothing was written.").FontSize(13).TextColor(t.TextMuted) + } + + if a.task.log != "" || live { + key := "log:" + task.ID + if _, set := a.open[key]; !set { + a.open[key] = live || task.State == "failed" + } + open := a.open[key] + ui.Collapsible(c, "Agent log", &open, func() { + ui.Scroll(c).MaxHeight(360).Radius(8).Background(t.Surface).Border(1, t.Border).Children(func() { + text := a.task.log + if text == "" { + text = "Waiting for the agent…" + } + ui.Text(c, text).Font("monospace").FontSize(11).LineHeight(1.4).Selectable().Padding(10) + }) + }) + a.open[key] = open + } + + details := a.open["details:"+task.ID] + ui.Collapsible(c, "Details", &details, func() { + ui.Column(c).Gap(8).Padding(8, 0, 0).Children(func() { + row := func(label, value string) { + ui.Row(c).Gap(12).AlignItems(ui.Start).Children(func() { + ui.Text(c, label).FontSize(12).TextColor(t.TextMuted).Width(90).Shrink(0) + ui.Text(c, value).FontSize(12).Selectable().Grow(1) + }) + } + if task.SourceKind != "prompt" { + row("Writes", a.m.destinationLabel(task.ActionConnectorID, task.ActionID)) + } + row("Queued", localTime(task.CreatedAt)) + if task.DurationMs != nil && *task.DurationMs > 0 { + row("Took", fmt.Sprintf("%.1fs", *task.DurationMs/1000)) + } + if task.Attempts > 1 { + row("Attempts", itoa(task.Attempts)) + } + if task.Prompt != nil && *task.Prompt != "" { + ui.Text(c, "Prompt").FontSize(12).TextColor(t.TextMuted) + ui.Scroll(c).MaxHeight(260).Children(func() { mono(c, *task.Prompt) }) + } + if task.AgentCommand != nil && *task.AgentCommand != "" { + ui.Text(c, "Command").FontSize(12).TextColor(t.TextMuted) + mono(c, *task.AgentCommand) + } + }) + }) + a.open["details:"+task.ID] = details +} diff --git a/desktop/loopform.go b/desktop/loopform.go new file mode 100644 index 0000000..71ce2e2 --- /dev/null +++ b/desktop/loopform.go @@ -0,0 +1,419 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" +) + +const defaultAgent = "Default agent" + +var pollChoices = []struct { + ms float64 + label string +}{ + {0, "Every couple of minutes"}, + {60_000, "Every minute"}, + {10 * 60_000, "Every 10 minutes"}, + {30 * 60_000, "Every 30 minutes"}, + {60 * 60_000, "Every hour"}, + {4 * 60 * 60_000, "Every 4 hours"}, +} + +var runsIn = map[string]string{ + "checkout": "A fresh clone of the repository", + "folder": "The folder this loop names", + "temp": "A scratch folder with only what it was given", +} + +var answers = map[string]string{ + "review": "A summary, plus comments on lines", + "code": "A change to the code", + "text": "One block of text", +} + +// fieldValues holds what a connector's generic fields are being edited to: +// one renderer for connection settings and loop conditions alike. +type fieldValues struct { + fields []SettingField + strs map[string]*string // text, string_list (newline-joined), select (label) + bools map[string]*bool +} + +func newFieldValues(fields []SettingField, stored map[string]any) *fieldValues { + v := &fieldValues{fields: fields, strs: map[string]*string{}, bools: map[string]*bool{}} + for _, f := range fields { + current := stored[f.Key] + switch f.Kind { + case "boolean": + b, ok := current.(bool) + if !ok { + b, _ = f.Default.(bool) + } + v.bools[f.Key] = &b + case "string_list": + var lines []string + if list, ok := current.([]any); ok { + for _, item := range list { + if s, ok := item.(string); ok { + lines = append(lines, s) + } + } + } + s := strings.Join(lines, "\n") + v.strs[f.Key] = &s + case "select": + value, ok := current.(string) + if !ok { + value, _ = f.Default.(string) + } + label := value + for _, option := range f.Options { + if option.Value == value { + label = option.Label + } + } + v.strs[f.Key] = &label + default: + s, _ := current.(string) + v.strs[f.Key] = &s + } + } + return v +} + +func (v *fieldValues) values() map[string]any { + out := map[string]any{} + for _, f := range v.fields { + switch f.Kind { + case "boolean": + out[f.Key] = *v.bools[f.Key] + case "string_list": + lines := []string{} + for _, line := range strings.Split(*v.strs[f.Key], "\n") { + if line = strings.TrimSpace(line); line != "" { + lines = append(lines, line) + } + } + out[f.Key] = lines + case "select": + label := *v.strs[f.Key] + value := label + for _, option := range f.Options { + if option.Label == label { + value = option.Value + } + } + out[f.Key] = value + default: + out[f.Key] = *v.strs[f.Key] + } + } + return out +} + +func (v *fieldValues) render(c *ui.Context) { + for _, f := range v.fields { + f := f + field := ui.Field(c, f.Label, func() { + switch f.Kind { + case "boolean": + ui.Switch(c, v.bools[f.Key]) + case "string_list": + ui.TextArea(c, v.strs[f.Key]).Lines(3, 8).Placeholder(f.Placeholder).MaxWidth(480) + case "select": + labels := make([]string, len(f.Options)) + for i, option := range f.Options { + labels[i] = option.Label + } + ui.Select(c, v.strs[f.Key], labels).MaxWidth(320) + default: + ui.TextInput(c, v.strs[f.Key]).Placeholder(f.Placeholder).MaxWidth(480) + } + }) + if f.Help != "" { + field.Description(f.Help) + } + } +} + +type loopForm struct { + key string + edit *LoopEdit + fresh bool + updatedAt string + + name, prompt, folder, mcp string + agent string + poll string + settings *fieldValues + actionConnectorID string + actionID string + target *fieldValues + writer, action string + written bool + saving bool + details bool +} + +func (a *app) newLoopForm(key string, edit *LoopEdit) *loopForm { + w := a.m.workflow(edit.ConnectorID, edit.WorkflowID) + f := &loopForm{key: key, edit: edit, fresh: edit.ID == nil, name: edit.Name, prompt: edit.Prompt} + if folder, ok := edit.Settings["folder"].(string); ok { + f.folder = strings.TrimPrefix(folder, "~/") + } + f.agent = defaultAgent + if edit.AgentID != nil { + f.agent = a.m.agentName(*edit.AgentID) + } + f.mcp = strings.Join(edit.McpServers, ", ") + var settings []SettingField + if w != nil { + settings = w.Settings + } + f.settings = newFieldValues(settings, edit.Settings) + ms := 0.0 + if edit.PollEveryMs != nil { + ms = *edit.PollEveryMs + } + f.poll = pollLabel(ms) + f.actionConnectorID, f.actionID = edit.ActionConnectorID, edit.ActionID + f.writer = a.m.writerName(edit.ActionConnectorID) + if action := a.m.action(edit.ActionConnectorID, edit.ActionID); action != nil { + f.action = action.Name + f.target = newFieldValues(action.Target, edit.ActionTarget) + } else { + f.target = newFieldValues(nil, nil) + } + f.written = strings.TrimSpace(edit.Prompt) != "" + return f +} + +func pollLabel(ms float64) string { + for _, choice := range pollChoices { + if choice.ms == ms { + return choice.label + } + } + return fmt.Sprintf("Every %d minutes", int(ms/60_000+0.5)) +} + +func (a *app) chooseDestination(f *loopForm, connectorID, actionID string) { + f.actionConnectorID, f.actionID = connectorID, actionID + f.writer = a.m.writerName(connectorID) + if action := a.m.action(connectorID, actionID); action != nil { + f.action = action.Name + f.target = newFieldValues(action.Target, nil) + } else { + f.action = "" + f.target = newFieldValues(nil, nil) + } +} + +func (a *app) loopFormView(c *ui.Context, f *loopForm) { + t := c.Theme() + edit := f.edit + w := a.m.workflow(edit.ConnectorID, edit.WorkflowID) + sourceName := a.m.connectorName(edit.ConnectorID) + if w == nil { + alert(c, "This workflow is no longer offered", []string{ + fmt.Sprintf("%s no longer has %s, so this loop cannot run or be edited. You can still delete it.", sourceName, edit.WorkflowID), + }, true) + return + } + action := a.m.action(f.actionConnectorID, f.actionID) + + section(c, "When", "When "+w.Trigger+".", func() { + ui.Form(c, func() { + ui.Field(c, "Name", func() { ui.TextInput(c, &f.name).MaxWidth(480) }) + f.settings.render(c) + if edit.ConnectorID != "schedule" { + labels := make([]string, len(pollChoices)) + for i, choice := range pollChoices { + labels[i] = choice.label + } + if !contains(labels, f.poll) { + labels = append(labels, f.poll) + } + ui.Field(c, "Check", func() { ui.Select(c, &f.poll, labels).MaxWidth(320) }) + } + }) + ui.Collapsible(c, "Fixed by the trigger", &f.details, func() { + ui.Column(c).Gap(6).Padding(10).Radius(8).Background(t.Surface).Children(func() { + row := func(label, value string) { + ui.Row(c).Gap(10).Children(func() { + ui.Text(c, label).FontSize(12).TextColor(t.TextMuted).Width(90).Shrink(0) + ui.Text(c, value).FontSize(12).Selectable() + }) + } + if w.Watches != "" { + row("Looks for", w.Watches) + } + runs := w.RunsIn + if runs == "" { + runs = "temp" + } + row("Runs in", runsIn[runs]) + row("Answer", answers[w.Answer]) + }) + }) + }) + + section(c, "Agent", "", func() { + ui.Form(c, func() { + names := []string{defaultAgent} + for _, agent := range a.m.Agents { + names = append(names, agent.Name) + } + ui.Field(c, "Run with", func() { ui.Select(c, &f.agent, names).MaxWidth(320) }) + ui.Field(c, "MCP servers", func() { + ui.TextInput(c, &f.mcp).Placeholder("None").Font("monospace").MaxWidth(420) + }).Description("Servers the agent may call without asking, named as on this Mac, such as sentry. Others are refused.") + if w.RunsIn == "folder" { + ui.Field(c, "Folder", func() { + ui.Row(c).Gap(4).AlignItems(ui.Center).MaxWidth(420).Children(func() { + ui.Text(c, "~/").Font("monospace").FontSize(13).TextColor(t.TextMuted) + ui.TextInput(c, &f.folder).Placeholder("code/web").Font("monospace").Grow(1) + }) + }).Description("Inside your home folder. The agent works here.") + } + field := ui.Field(c, "Prompt", func() { + ui.TextArea(c, &f.prompt).Lines(8, 24).Font("monospace").FontSize(12).Placeholder("What should the agent do?") + }) + if f.written { + field.Description(fmt.Sprintf("Starts as “%s”. Changes stay on this loop.", w.Name)) + } + }) + }) + + section(c, "Answer", "", func() { + ui.Form(c, func() { + var writerNames []string + writers := a.m.writers() + for _, writer := range writers { + writerNames = append(writerNames, a.m.writerName(writer.ID)) + } + ui.Field(c, "Send to", func() { + if ui.Select(c, &f.writer, writerNames).MaxWidth(320).Changed() { + for _, writer := range writers { + if a.m.writerName(writer.ID) == f.writer && writer.ID != f.actionConnectorID && len(writer.Actions) > 0 { + a.chooseDestination(f, writer.ID, writer.Actions[0].ID) + } + } + } + }) + if !isInboxOnly(f.actionConnectorID, f.actionID) { + var actionNames []string + if connector := a.m.connector(f.actionConnectorID); connector != nil { + for _, entry := range connector.Actions { + actionNames = append(actionNames, entry.Name) + } + } + ui.Field(c, "As", func() { + if ui.Select(c, &f.action, actionNames).MaxWidth(320).Changed() { + if connector := a.m.connector(f.actionConnectorID); connector != nil { + for _, entry := range connector.Actions { + if entry.Name == f.action && entry.ID != f.actionID { + a.chooseDestination(f, f.actionConnectorID, entry.ID) + } + } + } + } + }) + } + f.target.render(c) + }) + crosses := action != nil && f.actionConnectorID != edit.ConnectorID + flattens := action != nil && w.Answer == "review" && !contains(action.Accepts, "review") + if crosses && !isInboxOnly(f.actionConnectorID, f.actionID) { + ui.Text(c, sourceName+" and "+a.m.writerName(f.actionConnectorID)+" both need a connection.").FontSize(11).TextColor(t.TextMuted) + } else if flattens { + ui.Text(c, "Line comments arrive as text, with file and line written in.").FontSize(11).TextColor(t.TextMuted) + } + }) + + ui.Row(c).Gap(8).PaddingY(8).BorderWidth(1, 0, 0, 0).BorderColor(t.Border).Children(func() { + label := "Save changes" + if f.fresh { + label = "Add loop" + } + if f.saving { + label = "Saving…" + } + if ui.PrimaryButton(c, label).Disabled(f.saving).Clicked() { + a.saveLoop(f, w) + } + if ui.Button(c, "Cancel").Disabled(f.saving).Clicked() { + delete(a.forms, f.key) + if f.fresh { + a.goTo("/loops/new") + } else { + a.refresh() + } + } + }) +} + +func (a *app) saveLoop(f *loopForm, w *Workflow) { + edit := *f.edit + edit.Name = strings.TrimSpace(f.name) + edit.Prompt = f.prompt + edit.Guidance = nil + edit.AgentID = nil + if f.agent != defaultAgent { + for _, agent := range a.m.Agents { + if agent.Name == f.agent { + id := agent.ID + edit.AgentID = &id + } + } + } + edit.McpServers = []string{} + for _, server := range strings.FieldsFunc(f.mcp, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' }) { + if server != "" { + edit.McpServers = append(edit.McpServers, server) + } + } + edit.Settings = f.settings.values() + if w.RunsIn == "folder" { + edit.Settings["folder"] = strings.TrimSpace(f.folder) + } + edit.ActionConnectorID, edit.ActionID = f.actionConnectorID, f.actionID + edit.ActionTarget = f.target.values() + edit.PollEveryMs = nil + for _, choice := range pollChoices { + if choice.label == f.poll && choice.ms > 0 { + ms := choice.ms + edit.PollEveryMs = &ms + } + } + if edit.PollEveryMs == nil && strings.HasPrefix(f.poll, "Every ") && strings.HasSuffix(f.poll, " minutes") { + if n, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(f.poll, "Every "), " minutes")); err == nil && n > 0 { + ms := float64(n) * 60_000 + edit.PollEveryMs = &ms + } + } + f.saving = true + fresh := f.fresh + key := f.key + go func() { + var saved LoopView + err := a.call("saveLoop", edit, &saved) + a.update(func() { + f.saving = false + if err != nil { + a.toast(errText(err)) + return + } + delete(a.forms, key) + if fresh { + a.toast("Added " + saved.Name) + } else { + a.toast("Saved " + saved.Name) + } + a.goTo("/loops/" + saved.ID) + }) + }() +} diff --git a/desktop/loops.go b/desktop/loops.go new file mode 100644 index 0000000..63b6e62 --- /dev/null +++ b/desktop/loops.go @@ -0,0 +1,560 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" +) + +func (a *app) loopsPage(c *ui.Context) { + t := c.Theme() + a.header(c, "", "", "Loops", "When two loops match the same thing, the higher one runs.", "", func() { + if ui.PrimaryButton(c, "New loop").Clicked() { + a.goTo("/loops/new") + } + }) + if a.readiness != nil { + if gaps := a.gapsFor(a.readiness, a.loops); len(gaps) > 0 { + alert(c, "Nothing will run yet", gaps, false) + } + } + if len(a.loops) == 0 { + empty(c, "No loops yet.", func() { + if ui.Button(c, "New loop").Clicked() { + a.goTo("/loops/new") + } + }) + return + } + card(c).Dividers(1, t.Border).Children(func() { + for i := range a.loops { + a.loopRow(c, i) + } + }) +} + +func (a *app) summaryOf(loop *LoopView) string { + if a.m.workflow(loop.ConnectorID, loop.WorkflowID) == nil { + return "Workflow no longer offered" + } + agent := "Default agent" + if loop.AgentID != nil { + agent = a.m.agentName(*loop.AgentID) + } + return a.m.destinationLabel(loop.ActionConnectorID, loop.ActionID) + " · " + agent +} + +func (a *app) loopRow(c *ui.Context, i int) { + t := c.Theme() + loop := &a.loops[i] + busy := a.busy["loop:"+loop.ID] + var issues []string + if a.readiness != nil { + issues = a.issuesFor(a.readiness, loop) + } + ui.Row(c).Key(loop.ID).Gap(12).AlignItems(ui.Center).Padding(10, 16).Children(func() { + ui.Text(c, itoa(i+1)).FontSize(11).TextColor(t.TextMuted).Width(16).TextAlign(ui.Center).Shrink(0) + connectorTile(c, loop.ConnectorID, 28, !loop.Enabled) + link := ui.ButtonBase(c).Grow(1).Column().AlignItems(ui.Start).Gap(2).Label(loop.Name) + link.Children(func() { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Text(c, loop.Name).FontSize(13).Bold().SingleLine() + if !loop.Enabled { + ui.Badge(c, "Off") + } + }) + if len(issues) > 0 { + ui.Text(c, issues[0]).FontSize(11).TextColor(t.Warning).SingleLine() + } else { + ui.Text(c, a.summaryOf(loop)).FontSize(11).TextColor(t.TextMuted).SingleLine() + } + }) + if link.Clicked() { + a.goTo("/loops/" + loop.ID) + } + on := loop.Enabled + if ui.Switch(c, &on).Disabled(busy).Label("On").Changed() { + a.toggleLoop(loop, on) + } + ui.MenuButton(c, "", func(m *ui.Menu) { + if m.Item("Move up").Disabled(i == 0).Chosen() { + a.moveLoop(loop, "up") + } + if m.Item("Move down").Disabled(i == len(a.loops)-1).Chosen() { + a.moveLoop(loop, "down") + } + m.Separator() + if m.Item("Delete…").Chosen() { + a.askDeleteLoop(loop) + } + }).Label("More").Children(func() { ui.Icon(c, iconMore) }) + }) +} + +func (a *app) toggleLoop(loop *LoopView, on bool) { + id, name := loop.ID, loop.Name + loop.Enabled = on + a.run("loop:"+id, func() (string, error) { + if err := a.call("toggleLoop", map[string]any{"id": id, "enabled": on}, nil); err != nil { + return "", err + } + if on { + return name + " is on", nil + } + return name + " is off", nil + }) +} + +func (a *app) moveLoop(loop *LoopView, direction string) { + id := loop.ID + a.run("loop:"+id, func() (string, error) { + return "", a.call("reorderLoop", map[string]string{"id": id, "direction": direction}, nil) + }) +} + +func (a *app) askDeleteLoop(loop *LoopView) { + a.confirm = confirmState{ + open: true, kind: "loop", id: loop.ID, name: loop.Name, + title: "Delete " + loop.Name + "?", body: "Its runs are deleted with it.", + } +} + +// What stands between loops and running, as src/lib/gaps.ts words it. +func (a *app) wantedAgent(loop *LoopView, r *LoopReadiness) string { + if loop.AgentID != nil { + return *loop.AgentID + } + return deref(r.DefaultAgentID) +} + +func (a *app) runnersFor(loop *LoopView) string { + w := a.m.workflow(loop.ConnectorID, loop.WorkflowID) + if w == nil || w.RunsIn != "folder" { + return "any" + } + if folder, ok := loop.Settings["folder"].(string); ok && strings.HasPrefix(strings.TrimSpace(folder), "/") { + return "host" + } + return "folder" +} + +func (a *app) issuesFor(r *LoopReadiness, loop *LoopView) []string { + if !loop.Enabled { + return nil + } + var issues []string + agentID := a.wantedAgent(loop, r) + needs := a.runnersFor(loop) + pool := r.AvailableAgentIDs + switch needs { + case "host": + pool = r.HostAgentIDs + case "folder": + pool = r.FolderAgentIDs + } + if agentID == "" { + if loop.AgentID != nil { + issues = append(issues, fmt.Sprintf("%s uses %s, which is not signed in on this Mac.", loop.Name, a.m.agentName(*loop.AgentID))) + } else { + issues = append(issues, "No default agent is chosen, so this loop cannot run.") + } + } else if !contains(pool, agentID) { + if needs == "host" { + issues = append(issues, fmt.Sprintf("%s works in an absolute folder from an older version. Edit it and give the folder relative to your home.", loop.Name)) + } else { + issues = append(issues, fmt.Sprintf("%s uses %s, which is not signed in on this Mac.", loop.Name, a.m.agentName(agentID))) + } + } + return issues +} + +func (a *app) gapsFor(r *LoopReadiness, loops []LoopView) []string { + var missing []string + connected := map[string]bool{} + for _, id := range r.ConnectedConnectorIDs { + connected[id] = true + } + if len(connected) == 0 { + missing = append(missing, "Nothing is connected yet, so no signal can arrive.") + } + if len(r.AvailableAgentIDs) == 0 { + missing = append(missing, "No coding agent is signed in on this Mac, so nothing can be prepared.") + } else if r.DefaultAgentID == nil { + missing = append(missing, "No default agent is chosen, so loops that do not name one cannot run.") + } + if len(connected) > 0 { + for i := range loops { + loop := &loops[i] + if !loop.Enabled { + continue + } + if a.m.needsAccount(loop.ConnectorID) && !connected[loop.ConnectorID] { + missing = append(missing, fmt.Sprintf("%s watches %s, which is not connected.", loop.Name, a.m.connectorName(loop.ConnectorID))) + } + if a.m.needsAccount(loop.ActionConnectorID) && !connected[loop.ActionConnectorID] { + missing = append(missing, fmt.Sprintf("%s answers on %s, which is not connected.", loop.Name, a.m.connectorName(loop.ActionConnectorID))) + } + } + } + if len(r.AvailableAgentIDs) > 0 { + for i := range loops { + for _, issue := range a.issuesFor(r, &loops[i]) { + if issue == "No default agent is chosen, so this loop cannot run." { + continue + } + missing = append(missing, issue) + } + } + } + return missing +} + +// connected says whether loops can be made on a connector yet. +func (a *app) connected(id string) bool { + if a.readiness == nil { + return false + } + return contains(a.readiness.ConnectedConnectorIDs, id) +} + +func (a *app) usable(connector *Connector) bool { + return connector.Auth.Kind == "none" || a.connected(connector.ID) +} + +func (a *app) newLoopPage(c *ui.Context, r *ui.Route) { + t := c.Theme() + a.header(c, "/loops", "Loops", "New loop", "", "", nil) + if a.m == nil { + ui.Spinner(c) + return + } + inUse := func(workflowID string) string { + n := 0 + for i := range a.loops { + if a.loops[i].WorkflowID == workflowID { + n++ + } + } + switch n { + case 0: + return "" + case 1: + return "You have 1 loop like this" + } + return fmt.Sprintf("You have %d loops like this", n) + } + choice := func(key string, tileID, title, summary, note string, needs *Connector, target string) { + box := ui.ButtonBase(c).Key(key).Row().Gap(12).AlignItems(ui.Start).Padding(14).Radius(10).Background(t.Surface).Border(1, t.Border).Label(title).Basis(0).Grow(1).MinWidth(300) + if needs == nil && box.Hovered() { + box.Background(t.SurfaceHover) + } + box.Disabled(needs != nil) + box.Children(func() { + if tileID == "" { + ui.Box(c).Size(32, 32).Radius(8).Background(t.Background).Border(1, t.Border).Center().Shrink(0).Children(func() { + ui.Icon(c, iconPencil).FontSize(16) + }) + } else { + connectorTile(c, tileID, 32, needs != nil) + } + ui.Column(c).Grow(1).Gap(4).Children(func() { + ui.Text(c, title).FontSize(13).Bold() + ui.Text(c, summary).FontSize(11).TextColor(t.TextMuted) + if needs != nil { + ui.Link(c, "Connect "+needs.Name+" first", "/connectors/"+needs.ID).FontSize(11) + } else if note != "" { + ui.Text(c, note).FontSize(11).TextColor(t.TextMuted) + } + }) + }) + if box.Clicked() && needs == nil { + a.goTo(target) + } + } + grid := func(fn func()) { + ui.Row(c).Wrap().Gap(12).AlignItems(ui.Stretch).Children(fn) + } + grid(func() { + choice("custom", "", "Custom loop", "Pick a trigger and write your own prompt.", "", nil, "/loops/new/"+a.firstUsableTrigger()+"?custom=1") + for _, connector := range a.m.ordered() { + if connector.ID != "schedule" { + continue + } + for _, w := range connector.Workflows { + choice(connector.ID+"/"+w.ID, connector.ID, w.Name, w.Summary, inUse(w.ID), nil, "/loops/new/"+connector.ID+"/"+w.ID) + } + } + }) + ui.Text(c, "From a connector").FontSize(13).Bold().TextColor(t.TextMuted) + grid(func() { + for _, ready := range []bool{true, false} { + for _, connector := range a.m.ordered() { + if connector.ID == "schedule" || a.usable(connector) != ready { + continue + } + var needs *Connector + if !ready { + needs = connector + } + for _, w := range connector.Workflows { + choice(connector.ID+"/"+w.ID, connector.ID, w.Name, w.Summary, inUse(w.ID), needs, "/loops/new/"+connector.ID+"/"+w.ID) + } + } + } + }) + _ = r +} + +// firstUsableTrigger is where a custom loop starts: the first trigger that +// can actually run. +func (a *app) firstUsableTrigger() string { + var first, usable string + for _, connector := range a.m.ordered() { + for _, trigger := range connector.Triggers { + path := connector.ID + "/" + trigger.ID + if first == "" { + first = path + } + if connector.Auth.Kind != "none" && a.connected(connector.ID) { + return path + } + if usable == "" && a.usable(connector) { + usable = path + } + } + } + if usable != "" { + return usable + } + return first +} + +func (a *app) shapeLoopPage(c *ui.Context, connectorID, workflowID string, custom bool) { + t := c.Theme() + if a.m == nil { + ui.Spinner(c) + return + } + w := a.m.workflow(connectorID, workflowID) + if w == nil { + a.header(c, "/loops/new", "New loop", "Unknown workflow", "", "", nil) + return + } + key := "new:" + connectorID + "/" + workflowID + if custom { + key = "custom:" + connectorID + "/" + workflowID + } + form := a.forms[key] + if form == nil { + draft := a.m.draftFor(connectorID, workflowID) + if custom { + draft.Name, draft.Prompt = "Custom loop", "" + } + form = a.newLoopForm(key, draft) + a.forms[key] = form + } + if custom { + a.header(c, "/loops/new", "New loop", "Custom loop", "Pick what starts it, then say what the agent should do.", "", nil) + // The trigger: changing it starts the form over, since each has its + // own fields and its own place to answer. + var labels []string + var paths []string + current := "" + for _, connector := range a.m.ordered() { + for _, trigger := range connector.Triggers { + label := trigger.When + if !a.usable(connector) { + label += " (connect " + connector.Name + " first)" + } + labels = append(labels, label) + paths = append(paths, connector.ID+"/"+trigger.ID) + if connector.ID == connectorID && trigger.ID == workflowID { + current = label + } + } + } + ui.Field(c, "Starts when", func() { + if ui.Select(c, ¤t, labels).MaxWidth(420).Changed() { + for i, label := range labels { + if label == current && paths[i] != connectorID+"/"+workflowID { + a.goTo("/loops/new/" + paths[i] + "?custom=1") + } + } + } + }) + _ = t + } else { + a.header(c, "/loops/new", "New loop", w.Name, w.Summary, connectorID, nil) + } + a.loopFormView(c, form) +} + +func (a *app) loopDetailPage(c *ui.Context, r *ui.Route) { + t := c.Theme() + id := r.Param("id") + page := &a.loopPage + loop := page.loop + if loop == nil || loop.ID != id || a.m == nil { + a.header(c, "/loops", "Loops", "Loop", "", "", nil) + ui.Spinner(c) + return + } + connector := a.m.connector(loop.ConnectorID) + byHand := "none" + if connector != nil { + byHand = connector.ByHand.Kind + } + busy := a.busy["loop:"+loop.ID] + + summary := []string{a.m.destinationLabel(loop.ActionConnectorID, loop.ActionID)} + if loop.AgentID != nil { + summary = append(summary, a.m.agentName(*loop.AgentID)) + } else { + summary = append(summary, "Default agent") + } + if loop.Enabled && page.poll != nil { + if page.poll.PolledAt != nil { + summary = append(summary, "Checked "+ago(*page.poll.PolledAt)) + } else { + summary = append(summary, "Not checked yet") + } + } + a.header(c, "/loops", "Loops", loop.Name, strings.Join(summary, " · "), loop.ConnectorID, func() { + if byHand == "now" { + if ui.Button(c, "Run now").Disabled(busy).Clicked() { + a.run("loop:"+loop.ID, func() (string, error) { + if err := a.call("runLoopNow", map[string]any{"loopId": loop.ID, "url": "", "dryRun": false}, nil); err != nil { + return "", err + } + return "Queued", nil + }) + } + } else { + if ui.Button(c, "Check now").Disabled(busy || !loop.Enabled).Clicked() { + a.run("loop:"+loop.ID, func() (string, error) { + var report LookReport + if err := a.call("lookLoopNow", map[string]string{"id": loop.ID}, &report); err != nil { + return "", err + } + if report.Error != "" { + return "", fmt.Errorf("%s", report.Error) + } + return "Checked", nil + }) + } + } + on := loop.Enabled + if ui.Switch(c, &on).Disabled(busy).Label("On").Changed() { + a.toggleLoop(loop, on) + } + label := "Off" + if loop.Enabled { + label = "On" + } + ui.Text(c, label).FontSize(12) + }) + + if a.readiness != nil { + if issues := a.issuesFor(a.readiness, loop); len(issues) > 0 { + alert(c, "Cannot run yet", issues, false) + } + } + if loop.Enabled && page.poll != nil && page.poll.PollError != nil && *page.poll.PollError != "" { + alert(c, "The last check failed", []string{*page.poll.PollError}, true) + } + + tasksLabel := "Tasks" + if len(page.tasks) > 0 { + tasksLabel = fmt.Sprintf("Tasks %d", len(page.tasks)) + } + ui.Tabs(c, &page.tab, "Settings", tasksLabel) + + if page.tab == 0 { + key := "edit:" + loop.ID + form := a.forms[key] + if form == nil || form.updatedAt != loop.UpdatedAt { + form = a.newLoopForm(key, editOf(loop)) + form.updatedAt = loop.UpdatedAt + a.forms[key] = form + } + a.loopFormView(c, form) + section(c, "Delete", "", func() { + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + ui.Text(c, "Its runs are deleted with it.").FontSize(12).TextColor(t.TextMuted).Grow(1) + if ui.Button(c, "Delete loop").TextColor(t.Danger).Clicked() { + a.askDeleteLoop(loop) + } + }) + }) + return + } + + // Try the loop on one link, by hand. + if connector != nil && connector.ByHand.Kind == "link" { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(func() { + ui.TextInput(c, &page.tryURL).Placeholder("Try on a link: " + connector.ByHand.Placeholder).Label(connector.Name + " link").Grow(1) + ui.Switch(c, &page.dryRun).Label("Dry run") + ui.Text(c, "Dry run").FontSize(12).TextColor(t.TextMuted) + if ui.PrimaryButton(c, "Run").Disabled(busy || strings.TrimSpace(page.tryURL) == "").Clicked() { + url, dry := strings.TrimSpace(page.tryURL), page.dryRun + a.run("loop:"+loop.ID, func() (string, error) { + if err := a.call("runLoopNow", map[string]any{"loopId": loop.ID, "url": url, "dryRun": dry}, nil); err != nil { + return "", err + } + if dry { + return "Queued as a dry run", nil + } + return "Queued", nil + }) + page.tryURL = "" + } + }) + } + if page.poll != nil && len(page.poll.Backlog) > 0 { + held := 0 + for _, item := range page.poll.Backlog { + if item.Hold != nil { + held++ + } + } + before := len(page.poll.Backlog) - held + var reasons []string + if before > 0 { + verb := "were" + if before == 1 { + verb = "was" + } + reasons = append(reasons, fmt.Sprintf("%d %s open before this loop", before, verb)) + } + if held > 0 { + reasons = append(reasons, fmt.Sprintf("%d held back by a rule", held)) + } + open := a.open["backlog:"+loop.ID] + ui.Collapsible(c, fmt.Sprintf("%d not run · %s", len(page.poll.Backlog), strings.Join(reasons, ", ")), &open, func() { + card(c).Dividers(1, t.Border).Children(func() { + for _, item := range page.poll.Backlog { + item := item + ui.Row(c).Key(item.Key).Gap(12).AlignItems(ui.Center).Padding(8, 16).Children(func() { + ui.Column(c).Grow(1).Children(func() { + ui.Text(c, item.SourceRef+" "+item.SourceTitle).FontSize(12).SingleLine() + if item.Hold != nil { + ui.Text(c, *item.Hold).FontSize(11).TextColor(t.Warning).SingleLine() + } + }) + if ui.Button(c, "Run").Disabled(busy).Clicked() { + a.run("loop:"+loop.ID, func() (string, error) { + if err := a.call("runLoopBacklog", map[string]string{"id": loop.ID, "key": item.Key}, nil); err != nil { + return "", err + } + return "Queued", nil + }) + } + }) + } + }) + }) + a.open["backlog:"+loop.ID] = open + } + a.taskList(c, page.tasks, false, "No runs yet.") +} diff --git a/desktop/main.go b/desktop/main.go new file mode 100644 index 0000000..e8b4bf1 --- /dev/null +++ b/desktop/main.go @@ -0,0 +1,38 @@ +// Loopable for the desktop: the loops, the inbox, the connectors and the +// agents in a native window, with the engine (dispatcher, runner and API) +// running beside it in Node. See ../docs/desktop.md. +package main + +import ( + "log" + + "github.com/egoist/mygo" +) + +func main() { + mygo.App.SetName("Loopable") + if !mygo.App.RequestSingleInstanceLock() { + return + } + a := newApp() + mygo.App.OnSecondInstance(func([]string, string) { a.show() }) + mygo.App.WhenReady(a.ready) + // The window closing leaves the app in the menu bar; the Dock icon + // brings the window back. + mygo.App.OnWindowAllClosed(func() {}) + mygo.App.OnActivate(func(hasVisibleWindows bool) { + if !hasVisibleWindows { + a.show() + } + }) + mygo.App.OnBeforeQuit(func(*mygo.QuitEvent) { a.quitting = true }) + mygo.App.OnWillQuit(func(*mygo.QuitEvent) { + a.engine.Stop() + if a.tray != nil { + a.tray.Destroy() + } + }) + if err := mygo.App.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/desktop/model.go b/desktop/model.go new file mode 100644 index 0000000..8e3d0b9 --- /dev/null +++ b/desktop/model.go @@ -0,0 +1,517 @@ +package main + +// The engine's answers, as src/lib/domain.ts and the connector manifests +// shape them. Tags match the JSON exactly, since some of these go back. + +type Finding struct { + Path string `json:"path"` + Line int `json:"line"` + StartLine *int `json:"startLine,omitempty"` + Body string `json:"body"` +} + +type TaskView struct { + ID string `json:"id"` + LoopID *string `json:"loopId"` + LoopName string `json:"loopName"` + ConnectorID string `json:"connectorId"` + State string `json:"state"` + SourceURL string `json:"sourceUrl"` + SourceKind string `json:"sourceKind"` + SourceRef string `json:"sourceRef"` + SourceTitle *string `json:"sourceTitle"` + Prompt *string `json:"prompt"` + DryRun bool `json:"dryRun"` + AgentID *string `json:"agentId"` + RunnerID *string `json:"runnerId"` + AgentCommand *string `json:"agentCommand"` + Output *string `json:"output"` + Comments []Finding `json:"comments"` + ActionConnectorID string `json:"actionConnectorId"` + ActionID string `json:"actionId"` + ResultURL *string `json:"resultUrl"` + Error *string `json:"error"` + Attempts int `json:"attempts"` + CancelRequested bool `json:"cancelRequested"` + DurationMs *float64 `json:"durationMs"` + CreatedAt string `json:"createdAt"` + StartedAt *string `json:"startedAt"` + UpdatedAt string `json:"updatedAt"` +} + +func (t *TaskView) Title() string { + if t.SourceTitle != nil && *t.SourceTitle != "" { + return *t.SourceTitle + } + return t.SourceRef +} + +func (t *TaskView) Active() bool { + switch t.State { + case "queued", "preparing", "awaiting_agent", "applying": + return true + } + return false +} + +type LoopView struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Priority int `json:"priority"` + ConnectorID string `json:"connectorId"` + WorkflowID string `json:"workflowId"` + Settings map[string]any `json:"settings"` + Prompt string `json:"prompt"` + Guidance *string `json:"guidance"` + McpServers []string `json:"mcpServers"` + AgentID *string `json:"agentId"` + ActionConnectorID string `json:"actionConnectorId"` + ActionID string `json:"actionId"` + ActionTarget map[string]any `json:"actionTarget"` + PollEveryMs *float64 `json:"pollEveryMs"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type LoopEdit struct { + ID *string `json:"id"` + Name string `json:"name"` + ConnectorID string `json:"connectorId"` + WorkflowID string `json:"workflowId"` + Prompt string `json:"prompt"` + Guidance *string `json:"guidance"` + AgentID *string `json:"agentId"` + McpServers []string `json:"mcpServers"` + Settings map[string]any `json:"settings"` + ActionConnectorID string `json:"actionConnectorId"` + ActionID string `json:"actionId"` + ActionTarget map[string]any `json:"actionTarget"` + PollEveryMs *float64 `json:"pollEveryMs"` + Enabled bool `json:"enabled"` +} + +type LoopReadiness struct { + ConnectedConnectorIDs []string `json:"connectedConnectorIds"` + DefaultAgentID *string `json:"defaultAgentId"` + AvailableAgentIDs []string `json:"availableAgentIds"` + HostAgentIDs []string `json:"hostAgentIds"` + FolderAgentIDs []string `json:"folderAgentIds"` +} + +type BacklogItem struct { + Key string `json:"key"` + SourceKind string `json:"sourceKind"` + SourceRef string `json:"sourceRef"` + SourceTitle string `json:"sourceTitle"` + SourceURL string `json:"sourceUrl"` + Hold *string `json:"hold"` + SeenAt string `json:"seenAt"` +} + +type LoopPollState struct { + PolledAt *string `json:"polledAt"` + PollError *string `json:"pollError"` + Backlog []BacklogItem `json:"backlog"` +} + +type LookReport struct { + LoopName string `json:"loopName"` + Found int `json:"found"` + Queued int `json:"queued"` + Error string `json:"error"` +} + +type ConnectionView struct { + ID string `json:"id"` + ConnectorID string `json:"connectorId"` + Status string `json:"status"` + AccountLabel string `json:"accountLabel"` + AccountURL *string `json:"accountUrl"` + AvatarURL *string `json:"avatarUrl"` + Scopes []string `json:"scopes"` + Settings map[string]any `json:"settings"` + LastSyncedAt *string `json:"lastSyncedAt"` + LastError *string `json:"lastError"` + CreatedAt string `json:"createdAt"` +} + +type Readiness struct { + Ready bool `json:"ready"` + Reason string `json:"reason"` + FixHint string `json:"fixHint"` +} + +type ConnectorOverview struct { + ConnectorID string `json:"connectorId"` + Readiness Readiness `json:"readiness"` + Connections []ConnectionView `json:"connections"` +} + +type AgentOnRunner struct { + RunnerID string `json:"runnerId"` + Name string `json:"name"` + Status string `json:"status"` + SignedIn bool `json:"signedIn"` + Version *string `json:"version"` +} + +type AgentSettings struct { + PermissionMode string `json:"permissionMode"` + Model *string `json:"model"` + TimeoutMs float64 `json:"timeoutMs"` +} + +type AgentAuth struct { + SignedIn bool `json:"signedIn"` + Detail string `json:"detail"` +} + +type AgentView struct { + AgentID string `json:"agentId"` + Installed bool `json:"installed"` + BinaryPath *string `json:"binaryPath"` + Version *string `json:"version"` + Auth *AgentAuth `json:"auth"` + Settings AgentSettings `json:"settings"` + IsDefault bool `json:"isDefault"` + DefaultIsImplicit bool `json:"defaultIsImplicit"` + CommandPreview string `json:"commandPreview"` + OnRunners []AgentOnRunner `json:"onRunners"` +} + +type AgentChoice struct { + AgentID string `json:"agentId"` + Name string `json:"name"` +} + +type AgentTest struct { + Passed bool `json:"passed"` + Output string `json:"output"` + Detail *string `json:"detail"` + DurationMs float64 `json:"durationMs"` + Command string `json:"command"` +} + +type InventoryEntry struct { + AgentID string `json:"agentId"` + Installed bool `json:"installed"` + Version *string `json:"version"` + SignedIn bool `json:"signedIn"` + Detail string `json:"detail"` +} + +type RunnerView struct { + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + Status string `json:"status"` + Inventory []InventoryEntry `json:"inventory"` + LastSeenAt *string `json:"lastSeenAt"` + Version *string `json:"version"` + Enabled bool `json:"enabled"` +} + +type Status struct { + Version string `json:"version"` + PID int `json:"pid"` + DataDir string `json:"dataDir"` + Node string `json:"node"` + Alive bool `json:"alive"` + Runners []RunnerView `json:"runners"` + Paused bool `json:"paused"` + MaxConcurrentRuns int `json:"maxConcurrentRuns"` +} + +type SelectOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +type SettingField struct { + Key string `json:"key"` + Kind string `json:"kind"` + Label string `json:"label"` + Help string `json:"help"` + Placeholder string `json:"placeholder"` + Default any `json:"default"` + Options []SelectOption `json:"options"` +} + +type TokenField struct { + Key string `json:"key"` + Label string `json:"label"` + Placeholder string `json:"placeholder"` + Secret bool `json:"secret"` + Optional bool `json:"optional"` +} + +type AuthDescriptor struct { + Kind string `json:"kind"` + Scopes []string `json:"scopes"` + NeedsAppRegistration bool `json:"needsAppRegistration"` + Note string `json:"note"` + Fields []TokenField `json:"fields"` + HelpURL string `json:"helpUrl"` +} + +type ByHand struct { + Kind string `json:"kind"` + Placeholder string `json:"placeholder"` +} + +type Trigger struct { + ID string `json:"id"` + When string `json:"when"` + Trigger string `json:"trigger"` + Watches string `json:"watches"` + RunsIn string `json:"runsIn"` + Settings []SettingField `json:"settings"` + Answer string `json:"answer"` + ActionID string `json:"actionId"` + ActionConnectorID *string `json:"actionConnectorId"` + ActionTarget map[string]any `json:"actionTarget"` + PollEveryMs *float64 `json:"pollEveryMs"` +} + +// Workflow is a trigger with a workflow merged on, as connectorWorkflow +// returns it: what a loop runs, by the id it stores. +type Workflow struct { + ID string `json:"id"` + TriggerID string `json:"triggerId"` + Name string `json:"name"` + Summary string `json:"summary"` + Prompt string `json:"prompt"` + Custom bool `json:"custom"` + When string `json:"when"` + Trigger string `json:"trigger"` + Watches string `json:"watches"` + RunsIn string `json:"runsIn"` + Settings []SettingField `json:"settings"` + Answer string `json:"answer"` + ActionID string `json:"actionId"` + ActionConnectorID *string `json:"actionConnectorId"` + ActionTarget map[string]any `json:"actionTarget"` + PollEveryMs *float64 `json:"pollEveryMs"` +} + +type Action struct { + ID string `json:"id"` + Name string `json:"name"` + Summary string `json:"summary"` + Target []SettingField `json:"target"` + Accepts []string `json:"accepts"` +} + +type Connector struct { + ID string `json:"id"` + Name string `json:"name"` + Tagline string `json:"tagline"` + DocsURL *string `json:"docsUrl"` + Icon string `json:"icon"` + Auth AuthDescriptor `json:"auth"` + ByHand ByHand `json:"byHand"` + Settings []SettingField `json:"settings"` + AllowsMultipleAccounts bool `json:"allowsMultipleAccounts"` + Triggers []Trigger `json:"triggers"` + Workflows []Workflow `json:"workflows"` + Actions []Action `json:"actions"` +} + +type AgentManifest struct { + ID string `json:"id"` + Name string `json:"name"` + Tagline string `json:"tagline"` + DocsURL *string `json:"docsUrl"` + Icon string `json:"icon"` + Binaries []string `json:"binaries"` + InstallHint string `json:"installHint"` + PermissionModes []string `json:"permissionModes"` + SupportsModel bool `json:"supportsModel"` + ModelPlaceholder string `json:"modelPlaceholder"` + Defaults AgentSettings `json:"defaults"` +} + +type Manifests struct { + Connectors []Connector `json:"connectors"` + Agents []AgentManifest `json:"agents"` +} + +const inboxConnector, inboxAction = "schedule", "schedule.record" + +func (m *Manifests) connector(id string) *Connector { + if m == nil { + return nil + } + for i := range m.Connectors { + if m.Connectors[i].ID == id { + return &m.Connectors[i] + } + } + return nil +} + +func (m *Manifests) agent(id string) *AgentManifest { + if m == nil { + return nil + } + for i := range m.Agents { + if m.Agents[i].ID == id { + return &m.Agents[i] + } + } + return nil +} + +func (m *Manifests) agentName(id string) string { + if a := m.agent(id); a != nil { + return a.Name + } + return id +} + +func (m *Manifests) connectorName(id string) string { + if c := m.connector(id); c != nil { + return c.Name + } + return id +} + +// workflow is what a loop runs: a workflow, or a trigger alone for a custom loop. +func (m *Manifests) workflow(connectorID, workflowID string) *Workflow { + c := m.connector(connectorID) + if c == nil { + return nil + } + for i := range c.Workflows { + if c.Workflows[i].ID == workflowID { + return &c.Workflows[i] + } + } + for _, t := range c.Triggers { + if t.ID == workflowID { + w := customOn(t) + return &w + } + } + return nil +} + +func customOn(t Trigger) Workflow { + return Workflow{ + ID: t.ID, TriggerID: t.ID, Name: t.When, Summary: "When " + t.Trigger + ".", Custom: true, + When: t.When, Trigger: t.Trigger, Watches: t.Watches, RunsIn: t.RunsIn, Settings: t.Settings, + Answer: t.Answer, ActionID: t.ActionID, ActionConnectorID: t.ActionConnectorID, + ActionTarget: t.ActionTarget, PollEveryMs: t.PollEveryMs, + } +} + +func (m *Manifests) action(connectorID, actionID string) *Action { + c := m.connector(connectorID) + if c == nil { + return nil + } + for i := range c.Actions { + if c.Actions[i].ID == actionID { + return &c.Actions[i] + } + } + return nil +} + +func isInboxOnly(connectorID, actionID string) bool { + return connectorID == inboxConnector && actionID == inboxAction +} + +func (m *Manifests) writerName(connectorID string) string { + if connectorID == inboxConnector { + return "Inbox" + } + return m.connectorName(connectorID) +} + +func (m *Manifests) destinationLabel(connectorID, actionID string) string { + if isInboxOnly(connectorID, actionID) { + return "Inbox only" + } + if a := m.action(connectorID, actionID); a != nil { + return m.writerName(connectorID) + " · " + a.Name + } + return "No destination" +} + +// writers are the connectors that can write, Inbox first. +func (m *Manifests) writers() []*Connector { + var out []*Connector + for i := range m.Connectors { + if m.Connectors[i].ID == inboxConnector && len(m.Connectors[i].Actions) > 0 { + out = append(out, &m.Connectors[i]) + } + } + for i := range m.Connectors { + if m.Connectors[i].ID != inboxConnector && len(m.Connectors[i].Actions) > 0 { + out = append(out, &m.Connectors[i]) + } + } + return out +} + +func (m *Manifests) needsAccount(id string) bool { + c := m.connector(id) + return c != nil && c.Auth.Kind != "none" +} + +func (m *Manifests) connectionNoun(id string) string { + if c := m.connector(id); c != nil && c.Auth.Kind == "token" { + return "bot" + } + return "account" +} + +// ordered lists the connectors the clock first, as the new loop page does. +func (m *Manifests) ordered() []*Connector { + var out []*Connector + for i := range m.Connectors { + if m.Connectors[i].ID == "schedule" { + out = append(out, &m.Connectors[i]) + } + } + for i := range m.Connectors { + if m.Connectors[i].ID != "schedule" { + out = append(out, &m.Connectors[i]) + } + } + return out +} + +// draftFor is draftForWorkflow: one of a connector's workflows as a loop +// nobody has saved yet. +func (m *Manifests) draftFor(connectorID, workflowID string) *LoopEdit { + w := m.workflow(connectorID, workflowID) + if w == nil { + return nil + } + actionConnector := connectorID + if w.ActionConnectorID != nil { + actionConnector = *w.ActionConnectorID + } + target := w.ActionTarget + if target == nil { + target = map[string]any{} + } + return &LoopEdit{ + Name: w.Name, ConnectorID: connectorID, WorkflowID: workflowID, Prompt: w.Prompt, + McpServers: []string{}, Settings: map[string]any{}, ActionConnectorID: actionConnector, + ActionID: w.ActionID, ActionTarget: target, PollEveryMs: w.PollEveryMs, Enabled: true, + } +} + +func editOf(l *LoopView) *LoopEdit { + id := l.ID + return &LoopEdit{ + ID: &id, Name: l.Name, ConnectorID: l.ConnectorID, WorkflowID: l.WorkflowID, Prompt: l.Prompt, + Guidance: l.Guidance, AgentID: l.AgentID, McpServers: l.McpServers, Settings: l.Settings, + ActionConnectorID: l.ActionConnectorID, ActionID: l.ActionID, ActionTarget: l.ActionTarget, + PollEveryMs: l.PollEveryMs, Enabled: l.Enabled, + } +} diff --git a/desktop/mygo.json b/desktop/mygo.json new file mode 100644 index 0000000..95f19d0 --- /dev/null +++ b/desktop/mygo.json @@ -0,0 +1,13 @@ +{ + "name": "Loopable", + "identifier": "si.goodone.loopable", + "version": "0.1.0", + "copyright": "© 2026 Loopable", + "out": "build", + "macos": { + "minimumSystemVersion": "13.0", + "infoPlist": { + "LSApplicationCategoryType": "public.app-category.developer-tools" + } + } +} diff --git a/desktop/node.go b/desktop/node.go new file mode 100644 index 0000000..8699a69 --- /dev/null +++ b/desktop/node.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// A GUI app on macOS starts with almost no PATH, while Node and the agent +// CLIs live wherever a shell put them. The login shell knows; it is asked +// once, and the usual places are tried without it. + +const minNode = 22 + +func homeDirs() []string { + home, _ := os.UserHomeDir() + dirs := []string{ + "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", + filepath.Join(home, ".local/bin"), filepath.Join(home, ".bun/bin"), + filepath.Join(home, ".cargo/bin"), filepath.Join(home, ".volta/bin"), + filepath.Join(home, ".deno/bin"), filepath.Join(home, "n/bin"), + } + // Version managers keep every Node apart; the newest of each is enough. + for _, pattern := range []string{ + filepath.Join(home, ".nvm/versions/node/*/bin"), + filepath.Join(home, ".local/share/fnm/node-versions/*/installation/bin"), + filepath.Join(home, "Library/Application Support/fnm/node-versions/*/installation/bin"), + filepath.Join(home, ".local/state/fnm_multishells/*/bin"), + filepath.Join(home, ".asdf/installs/nodejs/*/bin"), + "/opt/homebrew/opt/node@*/bin", + } { + matches, _ := filepath.Glob(pattern) + sort.Sort(sort.Reverse(sort.StringSlice(matches))) + dirs = append(dirs, matches...) + } + return dirs +} + +// loginPath asks the login shell for its PATH, with a short patience. +func loginPath() string { + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/zsh" + } + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, shell, "-lc", `printf %s "$PATH"`).Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +// enginePath is the PATH the engine runs with: the login shell's, then the +// usual places, then whatever this process had. +func enginePath() string { + seen := map[string]bool{} + var dirs []string + add := func(list []string) { + for _, d := range list { + if d == "" || seen[d] { + continue + } + seen[d] = true + dirs = append(dirs, d) + } + } + add(strings.Split(loginPath(), ":")) + add(homeDirs()) + add(strings.Split(os.Getenv("PATH"), ":")) + return strings.Join(dirs, ":") +} + +type nodeInfo struct { + Path string + Version string + Major int +} + +// findNode looks along path for a Node new enough for the engine. +func findNode(path string) (nodeInfo, error) { + var best nodeInfo + var firstErr error + for _, dir := range strings.Split(path, ":") { + candidate := filepath.Join(dir, "node") + if _, err := os.Stat(candidate); err != nil { + continue + } + version, err := nodeVersion(candidate) + if err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + major := majorOf(version) + if major >= minNode && major > best.Major { + best = nodeInfo{Path: candidate, Version: version, Major: major} + } + } + if best.Path != "" { + return best, nil + } + if firstErr != nil { + return best, firstErr + } + return best, errNoNode +} + +type nodeError string + +func (e nodeError) Error() string { return string(e) } + +const errNoNode = nodeError("Node.js 22 or newer was not found. Install it from nodejs.org or with Homebrew, then restart Loopable.") + +func nodeVersion(bin string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, bin, "--version").Output() + if err != nil { + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +func majorOf(version string) int { + v := strings.TrimPrefix(version, "v") + if i := strings.Index(v, "."); i > 0 { + v = v[:i] + } + n, _ := strconv.Atoi(v) + return n +} diff --git a/desktop/render_test.go b/desktop/render_test.go new file mode 100644 index 0000000..783888a --- /dev/null +++ b/desktop/render_test.go @@ -0,0 +1,150 @@ +package main + +import ( + "image" + "image/png" + "os" + "path/filepath" + "testing" + + "github.com/egoist/mygo/ui" +) + +func ptr[T any](v T) *T { return &v } + +// fixture is enough of a workspace to draw every page. +func fixture() *app { + a := newApp() + a.engine.state = "running" + a.engine.port = 1 + a.m = &Manifests{ + Connectors: []Connector{ + { + ID: "github", Name: "GitHub", Tagline: "Reviews and replies on your repositories.", Icon: "Github", + Auth: AuthDescriptor{Kind: "oauth_redirect", Scopes: []string{"repo"}, Note: "Connect the account Loopable should answer as."}, + ByHand: ByHand{Kind: "link", Placeholder: "https://github.com/acme/web/pull/7"}, + AllowsMultipleAccounts: true, + Settings: []SettingField{{Key: "repositories", Kind: "string_list", Label: "Repositories", Help: "owner/name, one per line."}}, + Triggers: []Trigger{{ID: "github.review_requested", When: "A review is requested", Trigger: "someone asks for your review", Watches: "is:pr review-requested:@me", RunsIn: "checkout", Answer: "review", ActionID: "github.submit_review", + Settings: []SettingField{{Key: "drafts", Kind: "boolean", Label: "Include drafts", Default: false}}}}, + Workflows: []Workflow{{ID: "github.review", TriggerID: "github.review_requested", Name: "Review pull requests", Summary: "Review each pull request you are asked to.", Prompt: "Review this pull request.", When: "A review is requested", Trigger: "someone asks for your review", Watches: "is:pr review-requested:@me", RunsIn: "checkout", Answer: "review", ActionID: "github.submit_review", + Settings: []SettingField{{Key: "drafts", Kind: "boolean", Label: "Include drafts", Default: false}}}}, + Actions: []Action{{ID: "github.submit_review", Name: "Submit a review", Summary: "A review on the pull request.", Accepts: []string{"review", "text"}}}, + }, + { + ID: "schedule", Name: "Schedule", Tagline: "Run a loop when the time comes round.", Icon: "Clock", + Auth: AuthDescriptor{Kind: "none"}, ByHand: ByHand{Kind: "now"}, + Triggers: []Trigger{{ID: "schedule.recurring", When: "On a schedule", Trigger: "the time you set comes round", RunsIn: "folder", Answer: "text", ActionID: "schedule.record", + Settings: []SettingField{{Key: "every", Kind: "select", Label: "How often", Options: []SelectOption{{"hour", "Every hour"}, {"day", "Every day"}}, Default: "day"}, {Key: "at", Kind: "text", Label: "At", Help: "24-hour time, in UTC.", Placeholder: "09:00"}}}}, + Workflows: []Workflow{{ID: "schedule.daily", TriggerID: "schedule.recurring", Name: "Daily check", Summary: "Look over the folder every morning.", Prompt: "Look around.", When: "On a schedule", Trigger: "the time you set comes round", RunsIn: "folder", Answer: "text", ActionID: "schedule.record", + Settings: []SettingField{{Key: "every", Kind: "select", Label: "How often", Options: []SelectOption{{"hour", "Every hour"}, {"day", "Every day"}}, Default: "day"}, {Key: "at", Kind: "text", Label: "At", Help: "24-hour time, in UTC.", Placeholder: "09:00"}}}}, + Actions: []Action{{ID: "schedule.record", Name: "Keep in Inbox", Summary: "The answer stays here.", Accepts: []string{"text"}}}, + }, + {ID: "slack", Name: "Slack", Tagline: "A bot in your Slack.", Auth: AuthDescriptor{Kind: "token", Fields: []TokenField{{Key: "botToken", Label: "Bot User OAuth Token", Placeholder: "xoxb-…", Secret: true}}, Note: "Paste the bot token.", HelpURL: "https://api.slack.com/apps"}, + Triggers: []Trigger{{ID: "slack.mention", When: "The bot is mentioned", Trigger: "someone mentions the bot", RunsIn: "folder", Answer: "text", ActionID: "slack.reply"}}, + Workflows: []Workflow{{ID: "slack.answer", TriggerID: "slack.mention", Name: "Answer questions", Summary: "Answer in the thread.", Prompt: "Answer.", When: "The bot is mentioned", Trigger: "someone mentions the bot", RunsIn: "folder", Answer: "text", ActionID: "slack.reply"}}, + Actions: []Action{{ID: "slack.reply", Name: "Reply in Slack", Summary: "In the thread.", Accepts: []string{"text"}}}}, + }, + Agents: []AgentManifest{ + {ID: "codex", Name: "Codex", InstallHint: "npm install -g @openai/codex", PermissionModes: []string{"read_only", "workspace_write"}, SupportsModel: true, ModelPlaceholder: "gpt-5", Defaults: AgentSettings{PermissionMode: "read_only", TimeoutMs: 600000}}, + {ID: "claude", Name: "Claude Code", InstallHint: "npm install -g @anthropic-ai/claude-code", PermissionModes: []string{"read_only", "workspace_write"}, SupportsModel: true, Defaults: AgentSettings{PermissionMode: "read_only", TimeoutMs: 600000}}, + }, + } + a.loops = []LoopView{ + {ID: "l1", Name: "Review pull requests", Enabled: true, ConnectorID: "github", WorkflowID: "github.review", Prompt: "Review this pull request.", Settings: map[string]any{"drafts": false}, ActionConnectorID: "github", ActionID: "github.submit_review", ActionTarget: map[string]any{}, UpdatedAt: "2026-10-13T10:00:00Z"}, + {ID: "l2", Name: "Morning look", Enabled: false, ConnectorID: "schedule", WorkflowID: "schedule.daily", Prompt: "Look around.", Settings: map[string]any{"every": "day", "at": "09:00", "folder": "code/web"}, ActionConnectorID: "schedule", ActionID: "schedule.record", ActionTarget: map[string]any{}, AgentID: ptr("claude"), UpdatedAt: "2026-10-13T10:00:00Z"}, + } + a.readiness = &LoopReadiness{ConnectedConnectorIDs: []string{"github"}, DefaultAgentID: ptr("codex"), AvailableAgentIDs: []string{"codex", "claude"}, HostAgentIDs: []string{"codex", "claude"}, FolderAgentIDs: []string{"codex", "claude"}} + a.tasks = []TaskView{ + {ID: "t1", LoopID: ptr("l1"), LoopName: "Review pull requests", ConnectorID: "github", State: "awaiting_agent", SourceKind: "pull_request", SourceRef: "acme/web#7", SourceTitle: ptr("Fix checkout totals"), AgentID: ptr("codex"), ActionConnectorID: "github", ActionID: "github.submit_review", CreatedAt: "2026-10-13T12:00:00Z", UpdatedAt: "2026-10-13T12:00:00Z"}, + {ID: "t2", LoopID: ptr("l1"), LoopName: "Review pull requests", ConnectorID: "github", State: "done", SourceKind: "pull_request", SourceRef: "acme/web#6", SourceTitle: ptr("Add retries to the mailer"), AgentID: ptr("codex"), Output: ptr("Looks good overall. Two things to check before merging:\n\n1. The retry loop never backs off.\n2. The test only covers the happy path."), Comments: []Finding{{Path: "src/mailer.ts", Line: 42, Body: "This retries at once, with no wait between tries."}}, ActionConnectorID: "github", ActionID: "github.submit_review", ResultURL: ptr("https://github.com/acme/web/pull/6#pullrequestreview-1"), DurationMs: ptr(83000.0), CreatedAt: "2026-10-13T09:00:00Z", UpdatedAt: "2026-10-13T09:02:00Z"}, + {ID: "t3", LoopName: "", ConnectorID: "", State: "failed", SourceKind: "prompt", SourceRef: "Ask", SourceTitle: ptr("What changed this week?"), Prompt: ptr("What changed this week?"), AgentID: ptr("claude"), Error: ptr("The agent did not finish: timed out after 600s."), ActionConnectorID: "schedule", ActionID: "schedule.record", Attempts: 2, CreatedAt: "2026-10-12T09:00:00Z", UpdatedAt: "2026-10-12T09:12:00Z"}, + } + a.runnable = []AgentChoice{{"codex", "Codex"}, {"claude", "Claude Code"}} + a.agents = []AgentView{ + {AgentID: "codex", Installed: true, Version: ptr("0.42.0"), Auth: &AgentAuth{SignedIn: true, Detail: "Logged in using ChatGPT"}, Settings: AgentSettings{PermissionMode: "read_only", TimeoutMs: 600000}, IsDefault: true, CommandPreview: "codex exec --sandbox read-only "}, + {AgentID: "claude", Installed: true, Auth: &AgentAuth{SignedIn: false, Detail: "Not signed in"}, Settings: AgentSettings{PermissionMode: "workspace_write", TimeoutMs: 300000}, CommandPreview: "claude -p "}, + } + a.overview = []ConnectorOverview{ + {ConnectorID: "github", Readiness: Readiness{Ready: true}, Connections: []ConnectionView{{ID: "c1", ConnectorID: "github", Status: "connected", AccountLabel: "imsobear", Scopes: []string{"repo", "notifications"}, Settings: map[string]any{"repositories": []any{"acme/web"}}, LastSyncedAt: ptr("2026-10-13T12:00:00Z")}}}, + {ConnectorID: "slack", Readiness: Readiness{Ready: true}}, + {ConnectorID: "schedule", Readiness: Readiness{Ready: true}}, + } + a.status = &Status{Version: "0.8.1", DataDir: "/Users/me/Library/Application Support/Loopable/data", Alive: true, Runners: []RunnerView{{Name: "mac.local", Inventory: []InventoryEntry{{AgentID: "codex", SignedIn: true}}}}} + a.task = taskPage{id: "t2", task: &a.tasks[1], log: "codex: reading the diff…\ncodex: 3 files changed\ncodex: done"} + a.loopPage = loopPage{id: "l2", loop: &a.loops[1], tasks: a.tasks[:1], poll: &LoopPollState{PolledAt: ptr("2026-10-13T12:00:00Z"), Backlog: []BacklogItem{{Key: "b1", SourceRef: "acme/web#3", SourceTitle: "Old one", Hold: ptr("is a draft")}}}, dryRun: true} + a.settings.loaded = true + return a +} + +func shoot(t *testing.T, a *app, name, path string, w, h int) { + t.Helper() + a.router.Push(path) + a.enter(path) + tt := ui.NewTester(a.view, w, h) + for range 40 { + tt.Frame() + } + dir := os.Getenv("LOOPABLE_SHOTS") + if dir == "" { + return + } + _ = os.MkdirAll(dir, 0o755) + f, err := os.Create(filepath.Join(dir, name+".png")) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := encodePNG(f, tt.Image()); err != nil { + t.Fatal(err) + } +} + +func encodePNG(f *os.File, img image.Image) error { return png.Encode(f, img) } + +func TestPagesRender(t *testing.T) { + a := fixture() + pages := []struct{ name, path string }{ + {"inbox", "/inbox"}, {"task", "/inbox/t2"}, {"loops", "/loops"}, {"loop-new", "/loops/new"}, + {"loop-shape", "/loops/new/github/github.review"}, {"loop-custom", "/loops/new/schedule/schedule.recurring?custom=1"}, + {"loop-detail", "/loops/l2"}, {"connectors", "/connectors"}, {"connector", "/connectors/github"}, + {"connector-slack", "/connectors/slack"}, {"agents", "/agents"}, {"settings", "/settings"}, + } + for _, page := range pages { + shoot(t, a, page.name, page.path, 1040, 700) + } +} + +func TestInboxOpensATask(t *testing.T) { + a := fixture() + tt := ui.NewTester(a.view, 1040, 700) + if !tt.HasText("Fix checkout totals") { + t.Fatalf("inbox should list the runs: %q", tt.Texts()) + } + if err := tt.Click("Add retries to the mailer"); err != nil { + t.Fatal(err) + } + tt.Frame() + if a.router.Path() != "/inbox/t2" { + t.Fatalf("clicking a run should open it, got %s", a.router.Path()) + } + if !tt.HasText("Written") { + t.Fatalf("the run page should show the result: %q", tt.Texts()) + } +} + +func TestLoopFormSaves(t *testing.T) { + a := fixture() + a.router.Push("/loops/new/github/github.review") + tt := ui.NewTester(a.view, 1040, 900) + if !tt.HasText("Review pull requests") { + t.Fatalf("the form should be titled after the workflow: %q", tt.Texts()) + } + form := a.forms["new:github/github.review"] + if form == nil { + t.Fatal("no form was made") + } + if form.writer != "GitHub" || form.action != "Submit a review" { + t.Fatalf("the destination should start as the workflow's: %q %q", form.writer, form.action) + } +} diff --git a/desktop/resources/icon.png b/desktop/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..383c77a040a1226c14e8677d37efe01fce16ecff GIT binary patch literal 87043 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 >i>pxMRbrt=*2uN8FJ8`uVc~ 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

  • 2YI4s+ETc0iLI57z; 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 literal 0 HcmV?d00001 diff --git a/desktop/settings.go b/desktop/settings.go new file mode 100644 index 0000000..225be7b --- /dev/null +++ b/desktop/settings.go @@ -0,0 +1,169 @@ +package main + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/egoist/mygo" + "github.com/egoist/mygo/ui" +) + +type settingsPage struct { + loaded bool + openAtLogin bool + github string + gmailID string + gmailSecret string + oauthChanged bool +} + +func (a *app) settingsPage(c *ui.Context) { + t := c.Theme() + s := &a.settings + if !s.loaded { + s.loaded = true + s.openAtLogin = mygo.App.OpenAtLogin() + apps := readOAuthApps() + s.github, s.gmailID, s.gmailSecret = apps.GitHub.ClientID, apps.Gmail.ClientID, apps.Gmail.ClientSecret + } + a.header(c, "", "", "Settings", "This Mac runs the loops. Nothing leaves it but what the loops answer.", "", nil) + + state, detail, port, node, lines := a.engine.snapshot() + section(c, "Engine", "The dispatcher and the runner, in Node, started by the app.", func() { + card(c).Padding(14, 16).Gap(10).Children(func() { + row := func(label, value string) { + ui.Row(c).Gap(12).AlignItems(ui.Start).Children(func() { + ui.Text(c, label).FontSize(12).TextColor(t.TextMuted).Width(110).Shrink(0) + ui.Text(c, value).FontSize(12).Selectable().Grow(1) + }) + } + status := state + switch state { + case "running": + status = "Running" + if a.status != nil && a.status.Paused { + status = "Paused: no new runs start" + } + case "starting": + status = "Starting" + case "failed": + status = "Failed" + case "stopped": + status = "Stopped" + } + if detail != "" { + status += " · " + detail + } + row("Status", status) + if node.Path != "" { + row("Node", fmt.Sprintf("%s (%s)", node.Version, node.Path)) + } + if port != 0 { + row("Port", fmt.Sprintf("127.0.0.1:%d, this app only", port)) + } + if a.status != nil { + row("Version", "engine "+a.status.Version+", app "+mygo.App.Version()) + row("Data", a.status.DataDir) + for _, runner := range a.status.Runners { + var agents []string + for _, entry := range runner.Inventory { + if entry.SignedIn { + agents = append(agents, a.m.agentName(entry.AgentID)) + } + } + if len(agents) == 0 { + agents = []string{"no agent signed in"} + } + row("Runner", runner.Name+" · "+strings.Join(agents, ", ")) + } + } + ui.Row(c).Gap(8).Children(func() { + if a.status != nil { + paused := a.status.Paused + if ui.Switch(c, &paused).Label("Paused").Changed() { + a.status.Paused = paused + a.run("pause", func() (string, error) { + if err := a.call("pauseDispatcher", map[string]bool{"paused": paused}, nil); err != nil { + return "", err + } + if paused { + return "Paused. Loops are watched, but no run starts.", nil + } + return "Running again", nil + }) + } + ui.Text(c, "Pause: watch, but start no runs").FontSize(12).TextColor(t.TextMuted).Grow(1) + } else { + ui.Spacer(c) + } + if ui.Button(c, "Restart engine").Clicked() { + a.engine.Restart() + } + if ui.Button(c, "Open data folder").Clicked() { + _ = mygo.Shell.OpenPath(filepath.Join(userDataDir(), "data")) + } + }) + }) + }) + + section(c, "This Mac", "", func() { + ui.Row(c).Gap(10).AlignItems(ui.Center).Children(func() { + if ui.Switch(c, &s.openAtLogin).Label("Open at login").Changed() { + if err := mygo.App.SetOpenAtLogin(s.openAtLogin); err != nil { + a.toast(errText(err)) + s.openAtLogin = mygo.App.OpenAtLogin() + } + } + ui.Column(c).Gap(1).Children(func() { + ui.Text(c, "Open at login").FontSize(13) + ui.Text(c, "Starts in the menu bar, so loops run from the moment you sign in.").FontSize(11).TextColor(t.TextMuted) + }) + }) + }) + + section(c, "GitHub and Google apps", "The desktop app is a public client: it needs only client IDs. GitHub connects with a device code (turn on device flow in the OAuth app). Google needs a client of type Desktop app with the Gmail API enabled.", func() { + ui.Form(c, func() { + ui.Field(c, "GitHub client ID", func() { + if ui.TextInput(c, &s.github).Font("monospace").MaxWidth(420).Changed() { + s.oauthChanged = true + } + }) + ui.Field(c, "Google client ID", func() { + if ui.TextInput(c, &s.gmailID).Font("monospace").MaxWidth(420).Changed() { + s.oauthChanged = true + } + }) + ui.Field(c, "Google client secret", func() { + if ui.TextInput(c, &s.gmailSecret).Font("monospace").Password().MaxWidth(420).Changed() { + s.oauthChanged = true + } + }).Description("Google calls it a secret, but a desktop client's is not treated as one.") + }) + ui.Row(c).Gap(8).Children(func() { + if ui.PrimaryButton(c, "Save and restart engine").Disabled(!s.oauthChanged).Clicked() { + var apps OAuthApps + apps.GitHub.ClientID = strings.TrimSpace(s.github) + apps.Gmail.ClientID = strings.TrimSpace(s.gmailID) + apps.Gmail.ClientSecret = strings.TrimSpace(s.gmailSecret) + if err := writeOAuthApps(apps); err != nil { + a.toast(errText(err)) + return + } + s.oauthChanged = false + a.toast("Saved") + a.engine.Restart() + } + }) + }) + + section(c, "Engine log", "", func() { + text := strings.Join(lines, "\n") + if text == "" { + text = "Nothing yet." + } + ui.Scroll(c).MaxHeight(300).Radius(8).Background(t.Surface).Border(1, t.Border).Children(func() { + ui.Text(c, text).Font("monospace").FontSize(11).LineHeight(1.4).Selectable().Padding(10) + }) + }) +} diff --git a/desktop/shell.go b/desktop/shell.go new file mode 100644 index 0000000..740c9fa --- /dev/null +++ b/desktop/shell.go @@ -0,0 +1,290 @@ +package main + +import ( + "strings" + + "github.com/egoist/mygo/ui" +) + +const pageWidth = 820 + +func (a *app) view(c *ui.Context) { + for _, message := range a.toasts { + c.Toast(message) + } + a.toasts = nil + t := c.Theme() + + ui.Row(c).Fill().AlignItems(ui.Stretch).Children(func() { + a.sidebar(c) + ui.Column(c).Grow(1).Background(t.Background).Children(func() { + a.router.View(c, func(r *ui.Route) { + a.enter(r.Path()) + ui.Scroll(c).Fill().Children(func() { + ui.Column(c).Padding(28, 32, 40).Gap(20).MaxWidth(pageWidth).Children(func() { + state, detail, _, _, _ := a.engine.snapshot() + if state != "running" && r.Path() != "/settings" { + a.engineBanner(c, state, detail) + } + switch { + case r.Match("/inbox"): + r.Title("Inbox") + a.inboxPage(c) + case r.Match("/inbox/{id}"): + r.Title("Run") + a.taskPage(c, r.Param("id")) + case r.Match("/loops"): + r.Title("Loops") + a.loopsPage(c) + case r.Match("/loops/new"): + r.Title("New loop") + a.newLoopPage(c, r) + case r.Match("/loops/new/{connector}/{workflow}"): + r.Title("New loop") + a.shapeLoopPage(c, r.Param("connector"), r.Param("workflow"), r.Query("custom") == "1") + case r.Match("/loops/{id}"): + r.Title("Loop") + a.loopDetailPage(c, r) + case r.Match("/connectors"): + r.Title("Connectors") + a.connectorsPage(c) + case r.Match("/connectors/{id}"): + r.Title("Connector") + a.connectorPage(c, r.Param("id")) + case r.Match("/agents"): + r.Title("Agents") + a.agentsPage(c) + case r.Match("/settings"): + r.Title("Settings") + a.settingsPage(c) + default: + ui.Text(c, "Not found") + } + }) + }) + }) + }) + }) + a.confirmDialog(c) +} + +func (a *app) sidebar(c *ui.Context) { + t := c.Theme() + ui.Column(c).Width(196).Background(t.Surface).Shrink(0).BorderWidth(0, 1, 0, 0).BorderColor(t.Border).Children(func() { + ui.Row(c).Padding(18, 20, 10).Gap(8).AlignItems(ui.Center).Children(func() { + ui.Icon(c, iconLoops).FontSize(16) + ui.Text(c, "Loopable").FontSize(15).Bold() + }) + path := a.router.Path() + page := "/" + strings.SplitN(strings.TrimPrefix(path, "/"), "/", 2)[0] + ui.Sidebar(c, &page, func() { + ui.SidebarItem(c, "/inbox", iconInbox, "Inbox").Children(func() { + active := 0 + for i := range a.tasks { + if a.tasks[i].Active() { + active++ + } + } + if active > 0 { + ui.Badge(c, itoa(active)) + } + }) + ui.SidebarItem(c, "/loops", iconLoops, "Loops") + ui.SidebarItem(c, "/connectors", iconConnectors, "Connectors") + ui.SidebarItem(c, "/agents", iconAgents, "Agents") + ui.SidebarItem(c, "/settings", iconSettings, "Settings") + }).Grow(1).Label("Pages").OnChange(func() { + a.goTo(page) + }) + a.engineLine(c) + }) +} + +// engineLine is the one line at the bottom of the sidebar that says whether +// loops are running. +func (a *app) engineLine(c *ui.Context) { + t := c.Theme() + state, detail, _, _, _ := a.engine.snapshot() + label, color := "Engine starting…", t.TextMuted + switch state { + case "running": + label, color = "Running", t.Success + if a.status != nil && a.status.Paused { + label, color = "Paused", t.Warning + } + case "failed": + label, color = "Engine failed", t.Danger + case "stopped": + label, color = "Engine stopped", t.TextMuted + } + row := ui.ButtonBase(c).Padding(10, 20, 14).Gap(8).Row().AlignItems(ui.Center).Label("Engine: " + label).Tooltip(detail) + row.Children(func() { + ui.Box(c).Size(8, 8).Radius(4).Background(color) + ui.Text(c, label).FontSize(12).TextColor(t.TextMuted).SingleLine() + }) + if row.Clicked() { + a.goTo("/settings") + } +} + +func (a *app) engineBanner(c *ui.Context, state, detail string) { + t := c.Theme() + title := "The engine is starting" + if state == "failed" { + title = "The engine could not start" + } else if state == "stopped" { + title = "The engine is stopped" + } + ui.Row(c).Gap(12).AlignItems(ui.Center).Padding(12, 16).Radius(10).Background(t.Surface).Border(1, t.Border).Children(func() { + if state == "starting" { + ui.Spinner(c) + } else { + ui.Icon(c, iconAlert).TextColor(t.Danger) + } + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, title).FontSize(13).Bold() + if detail != "" { + ui.Text(c, detail).FontSize(12).TextColor(t.TextMuted) + } + }) + if ui.Button(c, "Settings").Clicked() { + a.goTo("/settings") + } + }) +} + +// header is the top of a page: a way back, a title, what it is, and what +// can be done to it. +func (a *app) header(c *ui.Context, back, backLabel, title, description string, tileID string, actions func()) { + t := c.Theme() + ui.Column(c).Gap(6).Children(func() { + if back != "" { + link := ui.ButtonBase(c).Row().Gap(2).AlignItems(ui.Center).Label("Back to " + backLabel).AlignSelf(ui.Start) + link.Children(func() { + ui.Text(c, "‹ "+backLabel).FontSize(12).TextColor(t.TextMuted) + }) + if link.Clicked() { + a.goTo(back) + } + } + ui.Row(c).Gap(12).AlignItems(ui.Center).Children(func() { + if tileID != "" { + connectorTile(c, tileID, 36, false) + } + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, title).FontSize(22).Bold() + if description != "" { + ui.Text(c, description).FontSize(13).TextColor(t.TextMuted) + } + }) + if actions != nil { + ui.Row(c).Gap(8).AlignItems(ui.Center).Children(actions) + } + }) + }) +} + +// card is a bordered block, as the web app's List and Card. +func card(c *ui.Context) ui.Element { + t := c.Theme() + return ui.Column(c).Radius(10).Background(t.Surface).Border(1, t.Border).Clip() +} + +// alert is a notice: a line or two that call for attention. +func alert(c *ui.Context, title string, lines []string, danger bool) { + t := c.Theme() + color := t.Border + if danger { + color = t.Danger + } + ui.Row(c).Gap(10).AlignItems(ui.Start).Padding(12, 14).Radius(10).Border(1, color).Children(func() { + ui.Icon(c, iconAlert).FontSize(16).TextColor(color).Padding(1, 0, 0) + ui.Column(c).Grow(1).Gap(2).Children(func() { + ui.Text(c, title).FontSize(13).Bold() + for _, line := range lines { + ui.Text(c, line).FontSize(12).TextColor(t.TextMuted).Selectable() + } + }) + }) +} + +// section is a titled group of a form, with a description under the title. +func section(c *ui.Context, title, description string, body func()) { + t := c.Theme() + ui.Column(c).Gap(12).PaddingY(8).Children(func() { + ui.Column(c).Gap(2).Children(func() { + ui.Text(c, title).FontSize(15).Bold() + if description != "" { + ui.Text(c, description).FontSize(12).TextColor(t.TextMuted) + } + }) + body() + }) +} + +func empty(c *ui.Context, text string, action func()) { + t := c.Theme() + ui.Column(c).Gap(10).Center().Padding(32).Radius(10).Border(1, t.Border).BorderStyle(ui.BorderDashed).Children(func() { + ui.Text(c, text).FontSize(13).TextColor(t.TextMuted) + if action != nil { + action() + } + }) +} + +func mono(c *ui.Context, text string) ui.Element { + t := c.Theme() + return ui.Text(c, text).Font("monospace").FontSize(11).Selectable().Padding(10).Radius(8).Background(t.Background).Border(1, t.Border) +} + +func (a *app) confirmDialog(c *ui.Context) { + if !a.confirm.open { + return + } + switch ui.AlertDialog(c, &a.confirm.open, a.confirm.title, a.confirm.body, "Cancel", "Delete") { + case 1: + kind, id, name := a.confirm.kind, a.confirm.id, a.confirm.name + switch kind { + case "loop": + a.run("delete:"+id, func() (string, error) { + if err := a.call("removeLoop", map[string]string{"id": id}, nil); err != nil { + return "", err + } + return "Deleted " + name, nil + }) + if strings.HasPrefix(a.router.Path(), "/loops/"+id) { + a.goTo("/loops") + } + case "connection": + a.run("disconnect:"+id, func() (string, error) { + if err := a.call("disconnectConnection", map[string]string{"id": id}, nil); err != nil { + return "", err + } + return "Disconnected " + name, nil + }) + } + } +} + +func itoa(n int) string { + return strings.TrimSpace(strings.Repeat(" ", 0) + fmtInt(n)) +} + +func fmtInt(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b []byte + for n > 0 { + b = append([]byte{byte('0' + n%10)}, b...) + n /= 10 + } + if neg { + b = append([]byte{'-'}, b...) + } + return string(b) +} diff --git a/docs/desktop.md b/docs/desktop.md new file mode 100644 index 0000000..b4853b4 --- /dev/null +++ b/docs/desktop.md @@ -0,0 +1,57 @@ +# The desktop app + +Loopable for one Mac: the loops, the inbox, the connectors and the agents in a native window, with no account and nothing hosted. Everything runs on the machine, and the window is the only way in. + +It lives in `desktop/`, written in Go with [MyGo](https://mygo.egoist.dev), whose native UI draws the screens on the GPU: no web page, no webview. The engine, everything that is not a screen, is the same TypeScript as the hosted service, bundled into one file and run as a Node process the app starts and stops. + +## What runs where + +| Part | Where | Does | +| --- | --- | --- | +| App | `desktop/*.go` | The window, the menu bar icon, notifications, open at login, settings | +| Engine | `src/desktop/engine.ts`, built to `desktop/resources/engine/engine.mjs` | The dispatcher (polls connectors, writes answers back), a runner in the same process, and a JSON API on loopback for the screens | + +The app starts the engine with a token it makes up at each launch, in the environment, and the engine takes requests only with that token, only on 127.0.0.1. No other program on the machine can ask it anything. When the app's stdin closes, the engine stops. + +The engine's data lives in `~/Library/Application Support/Loopable/data`: the SQLite file, and each run's log and scratch files. Credentials go in the keychain under the `loopable-desktop` namespace. The CLI runner's `~/.loopable` and `pnpm dev`'s `~/.loopable-dev` are untouched. + +The runner is `src/desktop/local-runner.ts`: the CLI runner with the HTTP taken out. It joins, heartbeats, claims and completes through the same functions the API routes call, so the database sees one runner, this machine, with the agents it finds installed. + +## Connecting services + +Slack, Feishu and WeChat connect as they do on the web: a pasted bot token, or a scan. + +GitHub and Gmail need an app registration, since the desktop app is a public client (it ships its client IDs and can keep no secret): + +- **GitHub.** An OAuth app with *device flow* turned on. The app shows a code to enter on github.com, and polls until GitHub hands over a token. No callback address and no client secret. Enter the client ID under Settings. +- **Google.** A client of type *Desktop app* in Google Cloud, with the Gmail API enabled. Google calls what it gives a desktop client a secret, but does not treat it as one. The browser comes back to the engine's loopback address, which desktop clients may use on any port. Enter the client ID and secret under Settings. + +Saving the IDs restarts the engine with them. They are kept in `oauth.json` in the app's data folder. + +## Developing + +Needs Go 1.27 or later and Node 22 or later. From the repository root: + +```bash +pnpm desktop +``` + +That builds the engine (`scripts/build-engine.mjs`: esbuild, the migrations, and libsql installed flat with npm) into `desktop/resources/engine`, then runs the app with `go tool mygo dev`, which rebuilds it as the Go changes. The engine is built once; run `pnpm build:engine` after changing anything under `src/`. `LOOPABLE_DEBUG=1`, which `pnpm desktop` sets, echoes the engine's log to the terminal. + +The screens draw without a window in tests, as pictures and as text, and one test loads them from a real engine in a throwaway folder: + +```bash +cd desktop && CGO_ENABLED=0 go test ./... +``` + +`LOOPABLE_SHOTS=/tmp/shots go test ./...` writes a PNG of every screen there, which is the quickest way to look at a change. + +## Packaging + +```bash +pnpm build:engine && cd desktop && go tool mygo build +``` + +That makes `desktop/build/darwin-arm64/Loopable.app` and a disk image, signed ad hoc until `mygo.json` names a Developer ID identity and a notarization profile; see MyGo's [distribution](https://mygo.egoist.dev/docs/distribution) docs. The engine directory ships inside the app's resources, and MyGo signs libsql's native module with the rest. + +The app has no updater yet. Adding one is MyGo's `updates` configuration and a release of the built app on GitHub. diff --git a/package.json b/package.json index eaa0aba..f9c4d6f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,8 @@ "runner": "LOOPABLE_DEV=1 node --experimental-strip-types src/cli.ts runner", "generate-routes": "tsr generate", "build": "vite build && node scripts/build-cli.mjs", + "build:engine": "node scripts/build-engine.mjs", + "desktop": "node scripts/build-engine.mjs && cd desktop && LOOPABLE_ENGINE_DIR=$PWD/resources/engine LOOPABLE_DEBUG=1 go tool mygo dev", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", diff --git a/scripts/build-engine.mjs b/scripts/build-engine.mjs new file mode 100644 index 0000000..804dced --- /dev/null +++ b/scripts/build-engine.mjs @@ -0,0 +1,65 @@ +import * as esbuild from "esbuild"; +import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { execSync } from "node:child_process"; + +/** + * The desktop engine: src/desktop/engine.ts and everything it reaches, as + * one file under desktop/resources/engine, beside the migrations and the one + * dependency that cannot be bundled, libsql's native module, installed flat + * with npm so the app ships no symlinks. + */ +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const src = join(root, "src"); +const out = join(root, "desktop", "resources", "engine"); +const pkg = JSON.parse(execSync("cat package.json", { cwd: root, encoding: "utf8" })); + +const aliases = { + name: "aliases", + setup(build) { + build.onResolve({ filter: /^[#@]\// }, (args) => ({ + path: join(src, args.path.slice(2)), + })); + }, +}; + +rmSync(out, { recursive: true, force: true }); +mkdirSync(out, { recursive: true }); + +await esbuild.build({ + bundle: true, + platform: "node", + format: "esm", + target: "node22", + absWorkingDir: root, + plugins: [aliases], + external: ["@libsql/client", "@libsql/client/*"], + legalComments: "none", + logLevel: "info", + entryPoints: [join(root, "src/desktop/engine.ts")], + outfile: join(out, "engine.mjs"), + // The bundle is ESM; some dependencies still ask for require(). + banner: { + js: 'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);', + }, +}); + +cpSync(join(root, "drizzle"), join(out, "drizzle"), { recursive: true }); + +writeFileSync( + join(out, "package.json"), + JSON.stringify( + { + name: "loopable-engine", + private: true, + version: pkg.version, + type: "module", + dependencies: { "@libsql/client": pkg.dependencies["@libsql/client"] }, + }, + null, + 2, + ), +); +execSync("npm install --omit=dev --no-audit --no-fund --loglevel=error", { cwd: out, stdio: "inherit" }); +console.log(`engine built in ${out}`); diff --git a/src/connectors/github/device.test.ts b/src/connectors/github/device.test.ts new file mode 100644 index 0000000..81e4e64 --- /dev/null +++ b/src/connectors/github/device.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { pollDeviceCode, requestDeviceCode } from "./device.ts"; + +function answering(body: unknown, calls: Array<{ url: string; body: unknown }> = []) { + const fetchImpl = (async (url: string, init?: RequestInit) => { + calls.push({ url, body: JSON.parse(String(init?.body)) }); + return new Response(JSON.stringify(body), { status: 200 }); + }) as unknown as typeof fetch; + return { fetchImpl, calls }; +} + +describe("GitHub device flow", () => { + it("asks for a code with the scopes, and reads what to show", async () => { + const { fetchImpl, calls } = answering({ + device_code: "dev-1", + user_code: "ABCD-EFGH", + verification_uri: "https://github.com/login/device", + expires_in: 900, + interval: 5, + }); + const code = await requestDeviceCode("client-1", ["repo", "notifications"], fetchImpl); + expect(code).toEqual({ + deviceCode: "dev-1", + userCode: "ABCD-EFGH", + verificationUri: "https://github.com/login/device", + expiresInMs: 900_000, + intervalMs: 5_000, + }); + expect(calls[0]).toEqual({ + url: "https://github.com/login/device/code", + body: { client_id: "client-1", scope: "repo notifications" }, + }); + }); + + it("says when device flow is off for the app", async () => { + const { fetchImpl } = answering({ error: "unauthorized_client", error_description: "nope" }); + await expect(requestDeviceCode("client-1", ["repo"], fetchImpl)).rejects.toThrow(/device flow turned off/); + }); + + it("keeps waiting while the code is not entered, and slows down when told", async () => { + expect(await pollDeviceCode("c", "d", answering({ error: "authorization_pending" }).fetchImpl)).toEqual({ + state: "pending", + slowDown: false, + }); + expect(await pollDeviceCode("c", "d", answering({ error: "slow_down" }).fetchImpl)).toEqual({ + state: "pending", + slowDown: true, + }); + }); + + it("gives up on an expired or declined code", async () => { + expect(await pollDeviceCode("c", "d", answering({ error: "expired_token" }).fetchImpl)).toMatchObject({ + state: "expired", + }); + expect(await pollDeviceCode("c", "d", answering({ error: "access_denied" }).fetchImpl)).toMatchObject({ + state: "expired", + }); + }); + + it("hands over the token once it is granted, without a client secret", async () => { + const { fetchImpl, calls } = answering({ access_token: "gho_x", expires_in: 3600, refresh_token: "ghr_y" }); + const outcome = await pollDeviceCode("client-1", "dev-1", fetchImpl); + expect(outcome.state).toBe("granted"); + if (outcome.state !== "granted") return; + expect(outcome.credential.accessToken).toBe("gho_x"); + expect(outcome.credential.refreshToken).toBe("ghr_y"); + expect(outcome.credential.expiresAt).toBeGreaterThan(Date.now()); + expect(calls[0]!.body).toEqual({ + client_id: "client-1", + device_code: "dev-1", + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }); + }); +}); diff --git a/src/connectors/github/device.ts b/src/connectors/github/device.ts new file mode 100644 index 0000000..4c6e476 --- /dev/null +++ b/src/connectors/github/device.ts @@ -0,0 +1,120 @@ +import type { GithubCredential } from "./oauth.ts"; + +/** + * GitHub's device flow: the app asks for a code, the person types it on + * github.com, and the app polls until GitHub hands over a token. Nothing comes + * back to the app's address, so the app needs none, and no client secret. + * The OAuth app must have device flow turned on in its settings. + */ + +type FetchLike = typeof fetch; + +export type DeviceCode = { + deviceCode: string; + userCode: string; + verificationUri: string; + expiresInMs: number; + intervalMs: number; +}; + +async function postJson( + url: string, + payload: Record, + fetchImpl: FetchLike, +): Promise> { + const res = await fetchImpl(url, { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const text = await res.text(); + try { + return JSON.parse(text) as Record; + } catch { + throw new Error(`GitHub returned a non-JSON response: ${text.slice(0, 200)}`); + } +} + +export async function requestDeviceCode( + clientId: string, + scopes: string[], + fetchImpl: FetchLike = fetch, +): Promise { + const body = await postJson( + "https://github.com/login/device/code", + { client_id: clientId, scope: scopes.join(" ") }, + fetchImpl, + ); + const error = typeof body.error === "string" ? body.error : ""; + if (error) { + const description = typeof body.error_description === "string" ? body.error_description : error; + throw new Error( + error === "unauthorized_client" + ? "This GitHub app has device flow turned off. Turn it on in the app's settings on GitHub." + : description, + ); + } + const deviceCode = typeof body.device_code === "string" ? body.device_code : ""; + const userCode = typeof body.user_code === "string" ? body.user_code : ""; + const verificationUri = typeof body.verification_uri === "string" ? body.verification_uri : ""; + if (!deviceCode || !userCode || !verificationUri) { + throw new Error("GitHub did not return a device code."); + } + const expiresIn = Number(body.expires_in); + const interval = Number(body.interval); + return { + deviceCode, + userCode, + verificationUri, + expiresInMs: (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 900) * 1000, + intervalMs: (Number.isFinite(interval) && interval > 0 ? interval : 5) * 1000, + }; +} + +export type DevicePoll = + | { state: "pending"; slowDown: boolean } + | { state: "expired"; reason: string } + | { state: "granted"; credential: GithubCredential }; + +export async function pollDeviceCode( + clientId: string, + deviceCode: string, + fetchImpl: FetchLike = fetch, +): Promise { + const body = await postJson( + "https://github.com/login/oauth/access_token", + { + client_id: clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }, + fetchImpl, + ); + const error = typeof body.error === "string" ? body.error : ""; + if (error === "authorization_pending") return { state: "pending", slowDown: false }; + if (error === "slow_down") return { state: "pending", slowDown: true }; + if (error === "expired_token") { + return { state: "expired", reason: "The code expired before it was entered." }; + } + if (error === "access_denied") { + return { state: "expired", reason: "The authorization was declined on GitHub." }; + } + if (error) { + const description = typeof body.error_description === "string" ? body.error_description : error; + throw new Error(description); + } + const accessToken = typeof body.access_token === "string" ? body.access_token : ""; + if (!accessToken) throw new Error("GitHub did not return an access token"); + const expiresIn = Number(body.expires_in); + return { + state: "granted", + credential: { + accessToken, + refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : undefined, + expiresAt: + Number.isFinite(expiresIn) && expiresIn > 0 + ? Date.now() + expiresIn * 1000 - 60_000 + : undefined, + }, + }; +} diff --git a/src/connectors/github/runtime.ts b/src/connectors/github/runtime.ts index 9314a37..3b38c98 100644 --- a/src/connectors/github/runtime.ts +++ b/src/connectors/github/runtime.ts @@ -8,6 +8,8 @@ import { pullForBranch, submitReview, } from "./api.ts"; +import { pollDeviceCode, requestDeviceCode } from "./device.ts"; +import { GITHUB_SCOPES } from "./manifest.ts"; import { pollGithub } from "./poll.ts"; import { githubRef, parseGithubRef, parseGithubUrl, resolveWorkItem } from "./work-item.ts"; import { @@ -211,5 +213,45 @@ export const githubRuntime = defineRuntime({ const account = accountFrom(await getViewer(accessToken)); return refreshed ? { account, renewedCredential: refreshed } : { account }; }, + + // Device flow: the desktop app has no callback address, and needs no + // client secret this way. The attempt carries what polling needs. + async startDeviceLogin() { + const registration = requireRegistration(); + const code = await requestDeviceCode(registration.clientId, GITHUB_SCOPES); + return { + attempt: { + deviceCode: code.deviceCode, + intervalMs: code.intervalMs, + expiresAt: Date.now() + code.expiresInMs, + }, + userCode: code.userCode, + verificationUrl: code.verificationUri, + expiresInMs: code.expiresInMs, + intervalMs: code.intervalMs, + }; + }, + async pollDeviceLogin(attempt) { + const registration = requireRegistration(); + const current = attempt as { deviceCode: string; intervalMs: number; expiresAt: number }; + if (Date.now() > current.expiresAt) { + return { state: "expired", reason: "The code expired before it was entered." }; + } + const outcome = await pollDeviceCode(registration.clientId, current.deviceCode); + if (outcome.state === "granted") { + const viewer = await getViewer(outcome.credential.accessToken); + return { state: "confirmed", result: { credential: outcome.credential, account: accountFrom(viewer) } }; + } + if (outcome.state === "expired") return { state: "expired", reason: outcome.reason }; + return { + state: "pending", + attempt: { + ...current, + // GitHub asks for five seconds more between polls when it says so. + intervalMs: outcome.slowDown ? current.intervalMs + 5_000 : current.intervalMs, + }, + hint: "Waiting for the code to be entered on GitHub…", + }; + }, }, }); diff --git a/src/connectors/oauth-app.test.ts b/src/connectors/oauth-app.test.ts index c3383fe..f644261 100644 --- a/src/connectors/oauth-app.test.ts +++ b/src/connectors/oauth-app.test.ts @@ -7,11 +7,13 @@ import { oauthAppRegistration } from "./oauth-app.ts"; const previousId = process.env.LOOPABLE_GITHUB_CLIENT_ID; const previousSecret = process.env.LOOPABLE_GITHUB_CLIENT_SECRET; const previousBase = process.env.LOOPABLE_BASE_URL; +const previousDesktop = process.env.LOOPABLE_DESKTOP; afterEach(() => { restore("LOOPABLE_GITHUB_CLIENT_ID", previousId); restore("LOOPABLE_GITHUB_CLIENT_SECRET", previousSecret); restore("LOOPABLE_BASE_URL", previousBase); + restore("LOOPABLE_DESKTOP", previousDesktop); }); function restore(key: string, value: string | undefined): void { @@ -29,6 +31,17 @@ describe("oauth app registration", () => { }); }); + it("takes a client ID alone in the desktop app, which keeps no secret", () => { + const cwd = vi.spyOn(process, "cwd").mockReturnValue(mkdtempSync(join(tmpdir(), "loopable-oauth-"))); + process.env.LOOPABLE_GITHUB_CLIENT_ID = "public-id"; + delete process.env.LOOPABLE_GITHUB_CLIENT_SECRET; + delete process.env.LOOPABLE_DESKTOP; + expect(oauthAppRegistration("github")).toBeNull(); + process.env.LOOPABLE_DESKTOP = "1"; + expect(oauthAppRegistration("github")).toEqual({ clientId: "public-id", clientSecret: "" }); + cwd.mockRestore(); + }); + it("has no app built in", () => { // Away from any config/oauth-app.json this checkout happens to have. const cwd = vi.spyOn(process, "cwd").mockReturnValue(mkdtempSync(join(tmpdir(), "loopable-oauth-"))); diff --git a/src/connectors/oauth-app.ts b/src/connectors/oauth-app.ts index 1458e52..3a0c57a 100644 --- a/src/connectors/oauth-app.ts +++ b/src/connectors/oauth-app.ts @@ -50,8 +50,18 @@ function fromFile(connectorId: string): OAuthAppRegistration | null { return pick(flat.clientId, flat.clientSecret); } +/** + * The desktop app is a public client: it ships its client IDs and cannot keep + * a secret, so GitHub connects by device flow and Google's desktop client + * treats its "secret" as public. An ID alone is a registration there. + */ +function isPublicClient(): boolean { + return process.env.LOOPABLE_DESKTOP === "1"; +} + function pick(id: unknown, secret: unknown): OAuthAppRegistration | null { const clientId = typeof id === "string" ? id.trim() : ""; const clientSecret = typeof secret === "string" ? secret.trim() : ""; - return clientId && clientSecret ? { clientId, clientSecret } : null; + if (clientId && clientSecret) return { clientId, clientSecret }; + return clientId && isPublicClient() ? { clientId, clientSecret: "" } : null; } diff --git a/src/connectors/types.ts b/src/connectors/types.ts index 6ffd5f3..50d6e40 100644 --- a/src/connectors/types.ts +++ b/src/connectors/types.ts @@ -299,6 +299,22 @@ export type QrOutcome = | { state: "expired"; reason?: string } | { state: "confirmed"; result: AuthResult }; +/** + * A login that finishes on the provider's site with a short code typed in, + * for an app that has no address a browser can be sent back to: the desktop + * app. Polled the way a QR scan is. + */ +export type DeviceChallenge = { + attempt: JsonValue; + /** What the person types on the provider's page. */ + userCode: string; + /** Where they type it. */ + verificationUrl: string; + expiresInMs: number; + /** How long to wait between polls; the provider says. */ + intervalMs: number; +}; + /** What a loop is acting on, resolved from a link or from a signal. */ export type WorkItem = WorkItemRef & { title: string; @@ -505,6 +521,12 @@ export type ConnectorRuntime = { /** Both required when auth.kind is "qr_scan". */ startQrLogin?(): Promise; pollQrLogin?(attempt: JsonValue): Promise; + /** + * Optional for "oauth_redirect": the same account, connected without a + * callback address, which the desktop app prefers. + */ + startDeviceLogin?(): Promise; + pollDeviceLogin?(attempt: JsonValue): Promise; /** Re-read the account to prove a stored credential still works. */ identity(credential: unknown): Promise; revoke?(credential: unknown): Promise; diff --git a/src/desktop/api.ts b/src/desktop/api.ts new file mode 100644 index 0000000..8a331a1 --- /dev/null +++ b/src/desktop/api.ts @@ -0,0 +1,291 @@ +import { randomBytes } from "node:crypto"; +import { toDataURL } from "qrcode"; +import { AGENT_MANIFESTS, fallbackAgent } from "#/agents/manifests.ts"; +import type { PermissionMode } from "#/agents/types.ts"; +import { CONNECTOR_MANIFESTS, connectorWorkflow } from "#/connectors/manifests.ts"; +import { connectorRuntime } from "#/connectors/runtimes.ts"; +import { dispatcherIsAlive } from "#/dispatcher/instance.ts"; +import type { AgentOnRunner, AgentView, ConnectionSettings, JsonValue, LoopEdit } from "#/lib/domain.ts"; +import { VERSION } from "#/lib/version.ts"; +import { listAgents, saveAgentSettings, setDefaultAgent, testAgent } from "#/server/agents.ts"; +import { + checkConnection, + listConnections, + removeConnection, + saveAuthorizedConnection, + startAuthAttempt, + updateConnectionSettings, +} from "#/server/connections.ts"; +import { + createLoop, + deleteLoop, + getLoop, + listLoops, + loopReadiness, + moveLoop, + setLoopEnabled, + updateLoop, +} from "#/server/loops.ts"; +import { dataDir } from "#/server/paths.ts"; +import { availableAgentIds, listRunners } from "#/server/runners.ts"; +import { dispatcherSettings, setDispatcherPaused } from "#/server/settings.ts"; +import { lookNow, loopPollState, runBacklog } from "#/server/signals.ts"; +import { + enqueuePrompt, + enqueueTask, + getTask, + listRunnableAgents, + listTasks, + readTaskLog, + requestCancel, +} from "#/server/tasks.ts"; + +/** + * What the desktop app can ask the engine, by name. The same operations the + * web pages reach through server functions, reached over plain JSON instead: + * the native screens are Go, and know nothing of TanStack. + * + * Kept as one map so the Go side has one list to mirror. + */ +export type RpcHandler = (data: never) => Promise; + +/** The engine's own address, set once it listens. OAuth callbacks come here. */ +let engineOrigin = ""; +export function setEngineOrigin(origin: string): void { + engineOrigin = origin; +} + +/** Copied from the agents server functions, which the engine cannot import. */ +async function withRunners(agents: AgentView[]): Promise { + const runners = await listRunners(); + const available = await availableAgentIds(); + const stored = agents.find((agent) => agent.isDefault)?.agentId ?? null; + const implicit = stored ? null : fallbackAgent(null, available); + return agents.map((agent) => { + const onRunners: AgentOnRunner[] = runners.flatMap((runner) => { + const entry = runner.inventory.find((item) => item.agentId === agent.agentId); + if (!entry?.installed) return []; + return [ + { + runnerId: runner.id, + name: runner.name, + status: runner.status, + signedIn: entry.signedIn, + version: entry.version, + }, + ]; + }); + const isDefault = agent.agentId === stored || agent.agentId === implicit; + return { + ...agent, + onRunners, + isDefault, + defaultIsImplicit: isDefault && agent.agentId === implicit, + }; + }); +} + +/** + * Every connector, with its workflows already merged onto their triggers, so + * the Go side never has to repeat connectorWorkflow. Agents come along, with + * their defaults, for the same reason. + */ +function manifests() { + return { + connectors: CONNECTOR_MANIFESTS.map((manifest) => ({ + id: manifest.id, + name: manifest.name, + tagline: manifest.tagline, + docsUrl: manifest.docsUrl ?? null, + icon: manifest.icon, + auth: manifest.auth, + byHand: manifest.byHand, + settings: manifest.settings, + allowsMultipleAccounts: manifest.allowsMultipleAccounts, + triggers: manifest.triggers, + workflows: manifest.workflows + .map((entry) => connectorWorkflow(manifest.id, entry.id)) + .filter((entry) => entry !== undefined), + actions: manifest.actions, + })), + agents: AGENT_MANIFESTS, + }; +} + +export const rpc = { + // Loops + getLoopsPage: async () => ({ loops: await listLoops(), readiness: await loopReadiness() }), + getLoopReadiness: async () => await loopReadiness(), + getLoopById: async (data: { id: string }) => await getLoop(data.id), + saveLoop: async (data: LoopEdit) => { + const { id, ...values } = data; + return id ? await updateLoop(id, values) : await createLoop(values); + }, + toggleLoop: async (data: { id: string; enabled: boolean }) => + await setLoopEnabled(data.id, data.enabled), + removeLoop: async (data: { id: string }) => { + await deleteLoop(data.id); + return await listLoops(); + }, + reorderLoop: async (data: { id: string; direction: "up" | "down" }) => + await moveLoop(data.id, data.direction), + getLoopPollState: async (data: { id: string }) => await loopPollState(data.id), + runLoopBacklog: async (data: { id: string; key?: string }) => ({ + queued: await runBacklog(data.id, data.key), + }), + lookLoopNow: async (data: { id: string }) => await lookNow(data.id), + + // Tasks + getInbox: async () => ({ tasks: await listTasks(), agents: await listRunnableAgents() }), + getLoopTasks: async (data: { loopId: string }) => await listTasks({ loopId: data.loopId }), + getTaskById: async (data: { id: string }) => await getTask(data.id), + getTaskLog: async (data: { id: string }) => await readTaskLog(data.id), + runPrompt: async (data: { prompt: string; agentId: string }) => await enqueuePrompt(data), + runLoopNow: async (data: { loopId: string; url: string; dryRun: boolean }) => + await enqueueTask(data), + stopTask: async (data: { id: string }) => await requestCancel(data.id), + getDispatcherState: async () => ({ + alive: await dispatcherIsAlive(), + hosted: false, + ...(await dispatcherSettings()), + }), + pauseDispatcher: async (data: { paused: boolean }) => await setDispatcherPaused(data.paused), + + // Agents + getAgentsPage: async () => ({ agents: await withRunners(await listAgents()) }), + saveAgent: async (data: { + agentId: string; + permissionMode: PermissionMode; + model: string | null; + timeoutMs: number; + }) => { + await saveAgentSettings(data.agentId, { + permissionMode: data.permissionMode, + model: data.model, + timeoutMs: data.timeoutMs, + }); + return await withRunners(await listAgents()); + }, + chooseDefaultAgent: async (data: { agentId: string }) => { + await setDefaultAgent(data.agentId); + return await withRunners(await listAgents()); + }, + runAgentTest: async (data: { agentId: string }) => { + const result = await testAgent(data.agentId); + return { + passed: result.passed, + output: result.output.slice(0, 2000), + detail: result.detail ?? null, + durationMs: result.durationMs, + command: result.command, + }; + }, + + // Connectors + getConnectorOverview: async () => { + const all = await listConnections(); + return Promise.all( + CONNECTOR_MANIFESTS.map(async (manifest) => ({ + connectorId: manifest.id, + readiness: await connectorRuntime(manifest.id).readiness(), + connections: all.filter((connection) => connection.connectorId === manifest.id), + })), + ); + }, + /** + * How to connect a browser-authorized connector from here: a device code + * when the connector offers one, else a page to open in the browser, which + * comes back to the engine's own loopback address. + */ + beginConnect: async (data: { connectorId: string }) => { + const runtime = connectorRuntime(data.connectorId); + if (runtime.auth.startDeviceLogin) { + return { kind: "device" as const, ...(await runtime.auth.startDeviceLogin()) }; + } + if (!runtime.auth.startAuthorization) { + throw new Error(`${data.connectorId} cannot start an authorization.`); + } + if (!engineOrigin) throw new Error("The engine has no address yet."); + const state = randomBytes(16).toString("base64url"); + const redirectUri = `${engineOrigin}/oauth/${data.connectorId}/callback`; + const { redirectUrl, verifier } = await runtime.auth.startAuthorization({ redirectUri, state }); + await startAuthAttempt({ connectorId: data.connectorId, state, verifier, redirectUri }); + return { kind: "browser" as const, url: redirectUrl }; + }, + continueDeviceLogin: async (data: { connectorId: string; attempt: JsonValue }) => { + const runtime = connectorRuntime(data.connectorId); + if (!runtime.auth.pollDeviceLogin) { + throw new Error(`${data.connectorId} does not connect with a device code.`); + } + const outcome = await runtime.auth.pollDeviceLogin(data.attempt); + if (outcome.state === "confirmed") { + const connection = await saveAuthorizedConnection(data.connectorId, outcome.result); + return { state: "connected" as const, accountLabel: connection.accountLabel }; + } + if (outcome.state === "expired") return { state: "expired" as const, reason: outcome.reason }; + return { state: "pending" as const, attempt: outcome.attempt, hint: outcome.hint }; + }, + beginQrLogin: async (data: { connectorId: string }) => { + const runtime = connectorRuntime(data.connectorId); + if (!runtime.auth.startQrLogin) { + throw new Error(`${data.connectorId} does not connect by scanning.`); + } + const challenge = await runtime.auth.startQrLogin(); + return { + attempt: challenge.attempt, + // A PNG, base64, for the Go side to decode and show. No data URL prefix. + image: (await toDataURL(challenge.encode, { margin: 1, width: 240 })).replace( + /^data:image\/png;base64,/, + "", + ), + expiresInMs: challenge.expiresInMs, + }; + }, + continueQrLogin: async (data: { connectorId: string; attempt: JsonValue }) => { + const runtime = connectorRuntime(data.connectorId); + if (!runtime.auth.pollQrLogin) { + throw new Error(`${data.connectorId} does not connect by scanning.`); + } + const outcome = await runtime.auth.pollQrLogin(data.attempt); + if (outcome.state === "confirmed") { + const connection = await saveAuthorizedConnection(data.connectorId, outcome.result); + return { state: "connected" as const, accountLabel: connection.accountLabel }; + } + if (outcome.state === "expired") return { state: "expired" as const, reason: outcome.reason }; + return { state: "pending" as const, attempt: outcome.attempt, hint: outcome.hint }; + }, + connectWithToken: async (data: { connectorId: string; fields: Record }) => { + const runtime = connectorRuntime(data.connectorId); + if (!runtime.auth.connectWithFields) { + throw new Error(`${data.connectorId} does not connect with a token.`); + } + const result = await runtime.auth.connectWithFields(data.fields); + const connection = await saveAuthorizedConnection(data.connectorId, result); + return { accountLabel: connection.accountLabel }; + }, + disconnectConnection: async (data: { id: string }) => { + await removeConnection(data.id); + return { ok: true }; + }, + verifyConnection: async (data: { id: string }) => await checkConnection(data.id), + saveConnectionSettings: async (data: { id: string; settings: ConnectionSettings }) => + await updateConnectionSettings(data.id, data.settings), + + // The desktop app itself + getManifests: async () => manifests(), + getStatus: async () => ({ + version: VERSION, + pid: process.pid, + dataDir: dataDir(), + node: process.version, + alive: await dispatcherIsAlive(), + runners: await listRunners(), + ...(await dispatcherSettings()), + }), +} satisfies Record; + +export type RpcName = keyof typeof rpc; + +export function isRpcName(name: string): name is RpcName { + return Object.hasOwn(rpc, name); +} diff --git a/src/desktop/engine.ts b/src/desktop/engine.ts new file mode 100644 index 0000000..10d1048 --- /dev/null +++ b/src/desktop/engine.ts @@ -0,0 +1,211 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { connectorRuntime } from "#/connectors/runtimes.ts"; +import { claimSingleInstance, releaseSingleInstance, touchDispatcher } from "#/dispatcher/instance.ts"; +import { saveAuthorizedConnection, takeAuthAttempt } from "#/server/connections.ts"; +import { migrateIfNeeded } from "#/server/db/client.ts"; +import { createPoller } from "#/server/poller.ts"; +import { sweepRunDirs } from "#/server/tasks.ts"; +import { createWorker } from "#/server/worker.ts"; +import { LOCAL_WORKSPACE, withWorkspace } from "#/server/workspace.ts"; +import { isRpcName, rpc, setEngineOrigin } from "./api.ts"; +import { startLocalRunner } from "./local-runner.ts"; + +/** + * The engine of the desktop app: the dispatcher, a runner, and a JSON API on + * loopback for the native screens, in one Node process the app starts and + * stops. Everything Loopable does happens here; the app draws it. + * + * Only the app knows the token, which it makes up at every launch and hands + * over in the environment, so no other program on the machine can ask. + */ + +const token = process.env.LOOPABLE_DESKTOP_TOKEN ?? ""; + +function log(message: string): void { + process.stderr.write(`${new Date().toISOString()} ${message}\n`); +} + +/** Said on stdout as JSON lines, which the app reads. Nothing else goes there. */ +function tell(event: Record): void { + process.stdout.write(`${JSON.stringify(event)}\n`); +} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + request.on("error", reject); + }); +} + +function json(response: ServerResponse, status: number, body: unknown): void { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body ?? null)); +} + +function html(response: ServerResponse, status: number, title: string, text: string): void { + response.writeHead(status, { "content-type": "text/html; charset=utf-8" }); + response.end( + `${title}` + + `

    ${title}

    ${text}

    ` + + `

    You can close this window and go back to Loopable.

    `, + ); +} + +function escape(text: string): string { + return text.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!); +} + +/** + * Where the browser comes back after a connector's authorization. The attempt + * says which connector and workspace; the state is the only proof needed. + */ +async function oauthCallback(connectorId: string, url: URL, response: ServerResponse): Promise { + const denied = url.searchParams.get("error"); + if (denied) { + html(response, 400, "Not connected", escape(url.searchParams.get("error_description") ?? denied)); + return; + } + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state) { + html(response, 400, "Not connected", "No authorization code came back."); + return; + } + try { + const attempt = await takeAuthAttempt(state); + if (attempt.connectorId !== connectorId) { + throw new Error("This authorization belongs to a different connector."); + } + const runtime = connectorRuntime(attempt.connectorId); + if (!runtime.auth.completeAuthorization) { + throw new Error("This connector cannot complete a browser authorization."); + } + const result = await runtime.auth.completeAuthorization({ + code, + redirectUri: attempt.redirectUri, + verifier: attempt.verifier, + }); + const connection = await withWorkspace(attempt.workspaceId, () => + saveAuthorizedConnection(attempt.connectorId, result), + ); + html(response, 200, "Connected", `Connected as ${escape(connection.accountLabel)}.`); + } catch (error) { + html(response, 400, "Not connected", escape(error instanceof Error ? error.message : String(error))); + } +} + +async function handle(request: IncomingMessage, response: ServerResponse): Promise { + const url = new URL(request.url ?? "/", "http://engine"); + const path = url.pathname; + + if (path === "/health") { + response.writeHead(200, { "content-type": "text/plain" }); + response.end("ok"); + return; + } + + const callback = /^\/oauth\/([^/]+)\/callback$/.exec(path); + if (callback && request.method === "GET") { + await oauthCallback(decodeURIComponent(callback[1]!), url, response); + return; + } + + const call = /^\/rpc\/([A-Za-z]+)$/.exec(path); + if (call && request.method === "POST") { + if ((request.headers.authorization ?? "") !== `Bearer ${token}`) { + json(response, 401, { error: "This engine is not open to you." }); + return; + } + const name = call[1]!; + if (!isRpcName(name)) { + json(response, 404, { error: `Unknown call: ${name}` }); + return; + } + let data: unknown = null; + try { + const body = await readBody(request); + data = body ? JSON.parse(body) : null; + } catch { + json(response, 400, { error: "The request body is not JSON." }); + return; + } + try { + const handler = rpc[name] as (data: unknown) => Promise; + const result = await withWorkspace(LOCAL_WORKSPACE, () => handler(data)); + json(response, 200, result); + } catch (error) { + json(response, 400, { error: error instanceof Error ? error.message : String(error) }); + } + return; + } + + response.writeHead(404); + response.end(); +} + +async function main(): Promise { + if (!token) { + log("LOOPABLE_DESKTOP_TOKEN is not set. The engine is started by the Loopable app."); + process.exit(2); + } + await migrateIfNeeded(); + const claim = await claimSingleInstance(); + if (!claim.ok) { + tell({ event: "busy", owner: claim.owner }); + log(`another Loopable dispatcher is already running (${claim.owner}).`); + process.exit(3); + } + + const swept = sweepRunDirs(); + if (swept > 0) log(`cleared ${swept} old run director${swept === 1 ? "y" : "ies"}`); + + const queue = createWorker({ log }); + const poller = createPoller({ log }); + const beat = setInterval(() => { + void touchDispatcher().catch((error: unknown) => { + log(`lease renew failed: ${error instanceof Error ? error.message : String(error)}`); + }); + }, 5_000); + + const server = createServer((request, response) => { + void handle(request, response).catch((error: unknown) => { + log(`request failed: ${error instanceof Error ? error.message : String(error)}`); + if (!response.headersSent) json(response, 500, { error: "The engine hit an error." }); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + setEngineOrigin(`http://127.0.0.1:${port}`); + + queue.start(); + poller.start(); + const runner = withWorkspace(LOCAL_WORKSPACE, () => startLocalRunner({ log })); + log(`engine started (pid ${process.pid}), listening on ${port}`); + tell({ event: "listening", port, pid: process.pid }); + + let shuttingDown = false; + const shutdown = async (reason: string) => { + if (shuttingDown) return; + shuttingDown = true; + log(`${reason}: stopping`); + clearInterval(beat); + poller.stop(); + server.close(); + await Promise.all([queue.stop(), runner.stop()]); + await releaseSingleInstance(); + log("stopped"); + process.exit(0); + }; + + process.on("SIGINT", () => void shutdown("SIGINT")); + process.on("SIGTERM", () => void shutdown("SIGTERM")); + // The app holds stdin open. When it is gone, so is the reason to run. + process.stdin.on("end", () => void shutdown("the app closed")); + process.stdin.on("error", () => void shutdown("the app closed")); + process.stdin.resume(); +} + +await main(); diff --git a/src/desktop/local-runner.ts b/src/desktop/local-runner.ts new file mode 100644 index 0000000..390f2d3 --- /dev/null +++ b/src/desktop/local-runner.ts @@ -0,0 +1,107 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { hostname as osHostname, tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectAgents } from "#/agents/inventory.ts"; +import { RUNNER_PROTOCOL, VERSION } from "#/lib/version.ts"; +import { runAgentJob } from "#/server/agent-runner.ts"; +import { appendAgentLog, claimAgentJob, completeAgentJob } from "#/server/runner-api.ts"; +import { getJoinToken, heartbeatRunner, instructionFor, joinRunner } from "#/server/runners.ts"; +import { taskRow } from "#/server/tasks.ts"; +import { watchFile } from "./watch-file.ts"; + +const BUILD = { version: VERSION, protocol: RUNNER_PROTOCOL, updatable: false }; + +/** + * The runner of the desktop app, in the same process as the dispatcher. It + * is the runner in src/runner/client.ts with the HTTP taken out: it joins, + * heartbeats, claims and completes through the same functions the API routes + * call, so what the database sees is one runner on this machine, as the + * Runners page always showed. + * + * It never updates itself: the app updates, and the engine with it. + */ +export function startLocalRunner(input: { log: (message: string) => void }) { + let stopping = false; + let current: AbortController | null = null; + let runnerId: string | null = null; + + async function joinHere(): Promise { + const hello = await joinRunner({ + joinToken: await getJoinToken(), + hostname: osHostname(), + inventory: await detectAgents(), + ...BUILD, + }); + input.log(`runner joined as ${hello.name} (${hello.runnerId})`); + return hello.runnerId; + } + + async function heartbeat(): Promise { + if (!runnerId) return; + await heartbeatRunner(runnerId, await detectAgents(), BUILD); + const instruction = await instructionFor(runnerId); + // Forgotten from the app is a fresh start, not an end: this machine is + // the only runner there is here. + if (instruction?.kind === "stop") runnerId = null; + } + + async function runOne(id: string): Promise { + const job = await claimAgentJob(id); + if (!job) return false; + input.log(`running ${job.taskId}`); + const workspace = mkdtempSync(join(tmpdir(), `loopable-${job.taskId}-`)); + const controller = new AbortController(); + current = controller; + const stopLog = watchFile(join(workspace, "agent.log"), (chunk) => { + void appendAgentLog(job.taskId, id, chunk).catch((error: unknown) => { + input.log(`log failed: ${error instanceof Error ? error.message : String(error)}`); + }); + }); + // A Stop pressed in the app leaves a note on the task; this is who reads + // it, since the agent is a child of this process. + const watchCancel = setInterval(() => { + void taskRow(job.taskId).then((row) => { + if (row?.cancelRequested) controller.abort(); + }); + }, 2_000); + try { + const result = await runAgentJob(job, { workspace, signal: controller.signal }); + await completeAgentJob(job.taskId, id, result); + } finally { + clearInterval(watchCancel); + stopLog(); + current = null; + rmSync(workspace, { recursive: true, force: true }); + } + return true; + } + + async function loop(): Promise { + let beatAt = 0; + while (!stopping) { + try { + if (!runnerId) runnerId = await joinHere(); + if (Date.now() - beatAt > 15_000) { + await heartbeat(); + beatAt = Date.now(); + if (!runnerId) continue; + } + const ran = await runOne(runnerId); + if (!ran) await new Promise((resolve) => setTimeout(resolve, 1_000)); + } catch (error) { + input.log(`runner: ${error instanceof Error ? error.message : String(error)}`); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + } + } + } + + const done = loop(); + + return { + async stop(): Promise { + stopping = true; + current?.abort(); + await done; + }, + }; +} diff --git a/src/desktop/watch-file.ts b/src/desktop/watch-file.ts new file mode 100644 index 0000000..3fc2ed4 --- /dev/null +++ b/src/desktop/watch-file.ts @@ -0,0 +1,25 @@ +import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs"; + +/** Tails a file that is being appended to, handing over each new piece. */ +export function watchFile(path: string, onChunk: (chunk: string) => void): () => void { + let offset = 0; + const flush = () => { + if (!existsSync(path)) return; + const fd = openSync(path, "r"); + try { + const size = fstatSync(fd).size; + if (size <= offset) return; + const buf = Buffer.alloc(size - offset); + readSync(fd, buf, 0, buf.length, offset); + offset = size; + onChunk(buf.toString("utf8")); + } finally { + closeSync(fd); + } + }; + const timer = setInterval(flush, 400); + return () => { + clearInterval(timer); + flush(); + }; +} From d7d1c9a8b11f55bf09a8066572524c0674d40730 Mon Sep 17 00:00:00 2001 From: imsobear Date: Tue, 13 Oct 2026 22:10:55 -0700 Subject: [PATCH 2/6] Load each desktop page from what the runner already found, not by spawning agents. Every page of the desktop app waited on the engine, which detected the three agent CLIs on every inbox and loops request, and a new page could not load while an older refresh was still running. Now the engine takes the agents from this host's runner inventory, which it detects on every heartbeat anyway, and the app loads each page's own data first, a newer refresh outranking an older one. Pages answer in tens of milliseconds. The engine also stops only when its parent is really gone, polling the parent id rather than trusting stdin's end; and the integration test waits for the runner to join before it looks at the pages, which it had reached too soon once they were fast. Co-Authored-By: Claude Fable 5.1 --- desktop/app.go | 187 +++++++++++++++++++++++------------------ desktop/engine_test.go | 13 ++- src/desktop/engine.ts | 27 +++++- src/server/agents.ts | 33 +++++++- 4 files changed, 170 insertions(+), 90 deletions(-) diff --git a/desktop/app.go b/desktop/app.go index 83401c8..08a08de 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -46,6 +46,7 @@ type app struct { // Task states last seen, to notice one finishing. seen map[string]string refreshing atomic.Bool + gen atomic.Uint64 lastTick time.Time } @@ -146,8 +147,10 @@ func (a *app) call(name string, in any, out any) error { return a.engine.Call(name, in, out) } -// refresh loads what the page on screen needs. Called on the main thread; -// the loading happens elsewhere and lands with update. +// refresh loads what the page on screen needs, that page's own data +// first, so a page never waits on another's. Called on the main thread; +// the loading happens elsewhere and lands with update. A newer refresh +// outranks an older one still in flight. func (a *app) refresh() { if a.engine == nil { return @@ -156,122 +159,125 @@ func (a *app) refresh() { if state != "running" { return } - if !a.refreshing.CompareAndSwap(false, true) { - return - } + a.refreshing.Store(true) + gen := a.gen.Add(1) path := a.router.Path() needManifests := a.m == nil taskID, loopID := a.task.id, a.loopPage.id + latest := func() bool { return a.gen.Load() == gen } go func() { - defer a.refreshing.Store(false) - var m *Manifests + defer func() { + if latest() { + a.refreshing.Store(false) + } + }() if needManifests { var got Manifests if err := a.call("getManifests", nil, &got); err == nil { - m = &got + a.update(func() { a.m = &got }) } } - var inbox struct { - Tasks []TaskView `json:"tasks"` - Agents []AgentChoice `json:"agents"` - } - inboxErr := a.call("getInbox", nil, &inbox) - - var page struct { - Loops []LoopView `json:"loops"` - Readiness LoopReadiness `json:"readiness"` - } - var loopsErr error = errSkip - if strings.HasPrefix(path, "/loops") || strings.HasPrefix(path, "/connectors/") { - loopsErr = a.call("getLoopsPage", nil, &page) - } - var task *TaskView - var log string - if strings.HasPrefix(path, "/inbox/") && taskID != "" { + switch { + case path == "/inbox": + a.loadInbox() + case strings.HasPrefix(path, "/inbox/") && taskID != "": var t TaskView if err := a.call("getTaskById", map[string]string{"id": taskID}, &t); err == nil && t.ID != "" { - task = &t var text *string + log := "" if a.call("getTaskLog", map[string]string{"id": taskID}, &text) == nil && text != nil { log = *text } + a.update(func() { + if a.task.id == t.ID { + a.task.task, a.task.log = &t, log + } + }) } - } - - var lp *loopPage - if strings.HasPrefix(path, "/loops/") && loopID != "" && path != "/loops/new" && !strings.HasPrefix(path, "/loops/new/") { + case path == "/loops" || path == "/loops/new" || strings.HasPrefix(path, "/loops/new/"): + a.loadLoops() + if path != "/loops" { + a.loadOverview() + } + case strings.HasPrefix(path, "/loops/") && loopID != "": var l LoopView if err := a.call("getLoopById", map[string]string{"id": loopID}, &l); err == nil && l.ID != "" { - lp = &loopPage{id: loopID, loop: &l} + lp := &loopPage{id: loopID, loop: &l} _ = a.call("getLoopTasks", map[string]string{"loopId": loopID}, &lp.tasks) var poll LoopPollState if a.call("getLoopPollState", map[string]string{"id": loopID}, &poll) == nil { lp.poll = &poll } + a.update(func() { + if a.loopPage.id == lp.id { + a.loopPage.loop, a.loopPage.tasks, a.loopPage.poll = lp.loop, lp.tasks, lp.poll + } + }) } - } - - var agents struct { - Agents []AgentView `json:"agents"` - } - agentsErr := errSkip - if path == "/agents" { - agentsErr = a.call("getAgentsPage", nil, &agents) - } - - var overview []ConnectorOverview - overviewErr := errSkip - if strings.HasPrefix(path, "/connectors") || strings.HasPrefix(path, "/loops") { - overviewErr = a.call("getConnectorOverview", nil, &overview) - } - - var status *Status - if path == "/settings" { + a.loadLoops() + case path == "/connectors": + a.loadOverview() + case strings.HasPrefix(path, "/connectors/"): + a.loadOverview() + a.loadLoops() + case path == "/agents": + var agents struct { + Agents []AgentView `json:"agents"` + } + if a.call("getAgentsPage", nil, &agents) == nil { + a.update(func() { a.agents = agents.Agents }) + } + case path == "/settings": var s Status if a.call("getStatus", nil, &s) == nil { - status = &s + a.update(func() { a.status = &s }) } } - a.update(func() { - if m != nil { - a.m = m - } - if inboxErr == nil { - a.noticeFinished(inbox.Tasks) - a.tasks, a.runnable = inbox.Tasks, inbox.Agents - if a.ask.agent == "" && len(inbox.Agents) > 0 { - a.ask.agent = inbox.Agents[0].Name - } - } - if loopsErr == nil { - a.loops, a.readiness = page.Loops, &page.Readiness - } - if task != nil && a.task.id == task.ID { - a.task.task, a.task.log = task, log - } - if lp != nil && a.loopPage.id == lp.id { - a.loopPage.loop, a.loopPage.tasks, a.loopPage.poll = lp.loop, lp.tasks, lp.poll - } - if agentsErr == nil { - a.agents = agents.Agents - } - if overviewErr == nil { - a.overview = overview - } - if status != nil { - a.status = status - } - }) + // The inbox feeds the sidebar's count and the notifications, so it + // is looked at after every page, when nothing newer is on its way. + if path != "/inbox" && latest() { + a.loadInbox() + } }() } -type skipError struct{} +func (a *app) loadInbox() { + var inbox struct { + Tasks []TaskView `json:"tasks"` + Agents []AgentChoice `json:"agents"` + } + if a.call("getInbox", nil, &inbox) != nil { + return + } + a.update(func() { + a.noticeFinished(inbox.Tasks) + a.tasks, a.runnable = inbox.Tasks, inbox.Agents + if a.ask.agent == "" && len(inbox.Agents) > 0 { + a.ask.agent = inbox.Agents[0].Name + } + }) +} -func (skipError) Error() string { return "skipped" } +func (a *app) loadLoops() { + var page struct { + Loops []LoopView `json:"loops"` + Readiness LoopReadiness `json:"readiness"` + } + if a.call("getLoopsPage", nil, &page) != nil { + return + } + a.update(func() { a.loops, a.readiness = page.Loops, &page.Readiness }) +} -var errSkip error = skipError{} +func (a *app) loadOverview() { + var overview []ConnectorOverview + if a.call("getConnectorOverview", nil, &overview) != nil { + return + } + a.update(func() { a.overview = overview }) +} // noticeFinished tells the person about a run that ended while they were // not looking, once per run. @@ -338,6 +344,13 @@ func (a *app) enter(path string) { if rest, ok := strings.CutPrefix(path, "/inbox/"); ok && rest != "" { if a.task.id != rest { a.task = taskPage{id: rest} + // What the list already knows, shown at once; the log follows. + for i := range a.tasks { + if a.tasks[i].ID == rest { + t := a.tasks[i] + a.task.task = &t + } + } } } if rest, ok := strings.CutPrefix(path, "/loops/"); ok && rest != "" && rest != "new" && !strings.HasPrefix(rest, "new/") { @@ -347,6 +360,12 @@ func (a *app) enter(path string) { } if a.loopPage.id != id { a.loopPage = loopPage{id: id, dryRun: true} + for i := range a.loops { + if a.loops[i].ID == id { + l := a.loops[i] + a.loopPage.loop = &l + } + } } } } @@ -404,7 +423,7 @@ func (a *app) ticker() { } else if !visible { every = 20 * time.Second } - if time.Since(a.lastTick) < every { + if time.Since(a.lastTick) < every || a.refreshing.Load() { return } a.lastTick = time.Now() diff --git a/desktop/engine_test.go b/desktop/engine_test.go index d67be8d..2ba5161 100644 --- a/desktop/engine_test.go +++ b/desktop/engine_test.go @@ -58,7 +58,18 @@ func startTestEngine(t *testing.T) *Engine { if e.port == 0 { t.Fatal("the engine did not start") } - return e + // The runner joins a moment after the engine listens, once it has + // looked for the agents; the pages are about what it found. + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + var s Status + if e.Call("getStatus", nil, &s) == nil && len(s.Runners) == 1 { + return e + } + time.Sleep(200 * time.Millisecond) + } + t.Fatal("the runner did not join") + return nil } func TestScreensWithARealEngine(t *testing.T) { diff --git a/src/desktop/engine.ts b/src/desktop/engine.ts index 10d1048..828bc04 100644 --- a/src/desktop/engine.ts +++ b/src/desktop/engine.ts @@ -202,9 +202,30 @@ async function main(): Promise { process.on("SIGINT", () => void shutdown("SIGINT")); process.on("SIGTERM", () => void shutdown("SIGTERM")); - // The app holds stdin open. When it is gone, so is the reason to run. - process.stdin.on("end", () => void shutdown("the app closed")); - process.stdin.on("error", () => void shutdown("the app closed")); + + // The app is the parent. When it is gone, so is the reason to run: an + // orphan belongs to launchd, and a parent that cannot be signalled is + // dead. Asked every couple of seconds, and whenever stdin, which the app + // holds open, ends; an end with the app still there is noise. + const parent = process.ppid; + const parentGone = () => { + if (process.ppid !== parent) return true; + try { + process.kill(parent, 0); + return false; + } catch { + return true; + } + }; + setInterval(() => { + if (parentGone()) void shutdown("the app is gone"); + }, 2_000).unref(); + const stdinEnded = () => { + if (parentGone()) void shutdown("the app closed"); + else log("stdin ended, but the app is still here; staying up"); + }; + process.stdin.on("end", stdinEnded); + process.stdin.on("error", stdinEnded); process.stdin.resume(); } diff --git a/src/server/agents.ts b/src/server/agents.ts index 438aab1..8e7d3ea 100644 --- a/src/server/agents.ts +++ b/src/server/agents.ts @@ -1,14 +1,15 @@ import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { hostname as osHostname, tmpdir } from "node:os"; import { join } from "node:path"; import { and, eq } from "drizzle-orm"; import { AGENT_MANIFESTS, agentManifest } from "#/agents/manifests.ts"; import { agentRuntime } from "#/agents/runtimes.ts"; import type { AgentRunResult, AgentSettingsValues } from "#/agents/types.ts"; -import type { AgentView } from "#/lib/domain.ts"; +import type { AgentView, RunnerInventoryEntry } from "#/lib/domain.ts"; import { isHosted } from "#/lib/hosted.ts"; import { db } from "./db/client.ts"; import { agentSettings } from "./db/schema.ts"; +import { listRunners } from "./runners.ts"; import { readSetting, writeSetting } from "./settings.ts"; import { workspaceId } from "./workspace.ts"; @@ -35,10 +36,15 @@ export async function settingsFor(agentId: string): Promise * Detection, authorization state and settings for every known agent. Detection * runs on every call because installing or removing a CLI should show up * without anyone pressing refresh. + * + * The desktop app is the exception: its runner, in the same process, detects + * the agents on every heartbeat, and spawning three CLIs on every page would + * make every page wait on them. Its inventory is what this host has. */ export async function listAgents(): Promise { const chosen = await storedSetting(DEFAULT_AGENT_KEY); const hosted = isHosted(); + const known = process.env.LOOPABLE_DESKTOP === "1" ? await hostInventory() : null; const views = await Promise.all( AGENT_MANIFESTS.map(async (manifest) => { @@ -64,6 +70,22 @@ export async function listAgents(): Promise { } satisfies AgentView; } + if (known) { + const entry = known.find((item) => item.agentId === manifest.id); + return { + agentId: manifest.id, + installed: entry?.installed ?? false, + binaryPath: null, + version: entry?.version ?? null, + auth: entry?.installed ? { signedIn: entry.signedIn, detail: entry.detail } : null, + settings, + isDefault: false, + defaultIsImplicit: false, + commandPreview: `${preview.bin} ${preview.args.join(" ")}`, + onRunners: [], + } satisfies AgentView; + } + const detection = await runtime.detect(); const auth = detection.installed ? await runtime.authStatus() : null; return { @@ -86,6 +108,13 @@ export async function listAgents(): Promise { ); } +/** What the runner on this machine last reported, or nothing before it joined. */ +async function hostInventory(): Promise { + const local = osHostname(); + const here = (await listRunners()).find((runner) => runner.hostname === local); + return here?.inventory ?? []; +} + export async function saveAgentSettings(agentId: string, values: AgentSettingsValues): Promise { const manifest = agentManifest(agentId); if (!manifest) throw new Error(`Unknown agent: ${agentId}`); From 603f9e983fe3ecf906ce7458d13e67887f0a513d Mon Sep 17 00:00:00 2001 From: imsobear Date: Tue, 13 Oct 2026 22:28:55 -0700 Subject: [PATCH 3/6] Build Loopable's GitHub and Google apps into the desktop app. Nobody using the desktop app should register an OAuth app, so the client IDs are no longer a Settings section. scripts/build-engine.mjs writes them beside the engine, from the environment when releasing or from the desktop entry of the gitignored config/oauth-app.json when developing, and a build without them says which connector it lacks. The app then shows that connector as not available rather than asking the person to fix it. GitHub's answer for an app with device flow off is device_flow_disabled, which now gets the same plain message as unauthorized_client. Co-Authored-By: Claude Opus 5.5 --- config/oauth-app.example.json | 7 ++++- desktop/connectors.go | 8 +++-- desktop/engine.go | 32 ++++++++++---------- desktop/settings.go | 45 ++-------------------------- docs/desktop.md | 8 ++--- scripts/build-engine.mjs | 33 +++++++++++++++++++- src/connectors/github/device.test.ts | 6 ++-- src/connectors/github/device.ts | 2 +- 8 files changed, 71 insertions(+), 70 deletions(-) diff --git a/config/oauth-app.example.json b/config/oauth-app.example.json index 270248b..1af73b1 100644 --- a/config/oauth-app.example.json +++ b/config/oauth-app.example.json @@ -1,5 +1,10 @@ { "_comment": "Copy to config/oauth-app.json (gitignored) to sign in and connect while developing. Register each app with the loopback callback: GitHub http://127.0.0.1:4321/api/connectors/github/callback, Google http://127.0.0.1:4321/api/connectors/gmail/callback.", "github": { "clientId": "", "clientSecret": "" }, - "gmail": { "clientId": "", "clientSecret": "" } + "gmail": { "clientId": "", "clientSecret": "" }, + "desktop": { + "_comment": "Built into the desktop app by scripts/build-engine.mjs. GitHub: an OAuth app with device flow turned on, ID only. Google: a client of type Desktop app.", + "github": { "clientId": "" }, + "gmail": { "clientId": "", "clientSecret": "" } + } } diff --git a/desktop/connectors.go b/desktop/connectors.go index 7f0a736..5f3c485 100644 --- a/desktop/connectors.go +++ b/desktop/connectors.go @@ -52,7 +52,7 @@ func (a *app) connectorsPage(c *ui.Context) { ui.Text(c, manifest.Name).FontSize(13).Bold() switch { case state != nil && !state.Readiness.Ready: - ui.Text(c, state.Readiness.Reason).FontSize(11).TextColor(t.TextMuted).SingleLine() + ui.Text(c, "Not available in this build").FontSize(11).TextColor(t.TextMuted).SingleLine() case state != nil && len(state.Connections) > 0: ui.Row(c).Gap(10).AlignItems(ui.Center).Wrap().Children(func() { for _, connection := range state.Connections { @@ -150,13 +150,15 @@ func (a *app) connectorPage(c *ui.Context, id string) { }) if state != nil && !ready { + // Loopable's own apps are built in, so this is the build's doing, + // and there is nothing for the person to set up. lines := []string{state.Readiness.Reason} if manifest.Auth.Kind == "oauth_redirect" { - lines = append(lines, "Enter the app's client ID under Settings. The desktop app connects GitHub with a device code and Google with a desktop client, so no callback address is needed.") + lines = []string{"This copy of Loopable was built without its " + manifest.Name + " app, so " + manifest.Name + " cannot be connected yet. A newer version will fix it."} } else if state.Readiness.FixHint != "" { lines = append(lines, state.Readiness.FixHint) } - alert(c, "Not set up", lines, true) + alert(c, "Not available", lines, true) } if a.connect != nil { diff --git a/desktop/engine.go b/desktop/engine.go index 6a62c90..084d457 100644 --- a/desktop/engine.go +++ b/desktop/engine.go @@ -40,9 +40,11 @@ type Engine struct { client *http.Client } -// OAuthApps is what the user registered with the providers, kept in -// oauth.json in the app's data folder. Client IDs only; the desktop app is -// a public client. +// OAuthApps are Loopable's own GitHub and Google apps, built into the app +// by scripts/build-engine.mjs as oauth-apps.json beside the engine. Client +// IDs only, and Google's desktop "secret": the app is a public client. +// oauth.json in the app's data folder can stand in for them while +// developing; nobody using the app sees either. type OAuthApps struct { GitHub struct { ClientID string `json:"clientId"` @@ -77,20 +79,20 @@ func userDataDir() string { func oauthFile() string { return filepath.Join(userDataDir(), "oauth.json") } func readOAuthApps() OAuthApps { - var apps OAuthApps - data, err := os.ReadFile(oauthFile()) - if err == nil { - _ = json.Unmarshal(data, &apps) + var built, local OAuthApps + if data, err := os.ReadFile(filepath.Join(engineDir(), "oauth-apps.json")); err == nil { + _ = json.Unmarshal(data, &built) } - return apps -} - -func writeOAuthApps(apps OAuthApps) error { - data, err := json.MarshalIndent(apps, "", " ") - if err != nil { - return err + if data, err := os.ReadFile(oauthFile()); err == nil { + _ = json.Unmarshal(data, &local) + } + if local.GitHub.ClientID != "" { + built.GitHub = local.GitHub + } + if local.Gmail.ClientID != "" { + built.Gmail = local.Gmail } - return os.WriteFile(oauthFile(), data, 0o600) + return built } // engineDir is where engine.mjs lives: beside the app's resources, or diff --git a/desktop/settings.go b/desktop/settings.go index 225be7b..d7a5fa4 100644 --- a/desktop/settings.go +++ b/desktop/settings.go @@ -10,12 +10,8 @@ import ( ) type settingsPage struct { - loaded bool - openAtLogin bool - github string - gmailID string - gmailSecret string - oauthChanged bool + loaded bool + openAtLogin bool } func (a *app) settingsPage(c *ui.Context) { @@ -24,8 +20,6 @@ func (a *app) settingsPage(c *ui.Context) { if !s.loaded { s.loaded = true s.openAtLogin = mygo.App.OpenAtLogin() - apps := readOAuthApps() - s.github, s.gmailID, s.gmailSecret = apps.GitHub.ClientID, apps.Gmail.ClientID, apps.Gmail.ClientSecret } a.header(c, "", "", "Settings", "This Mac runs the loops. Nothing leaves it but what the loops answer.", "", nil) @@ -122,41 +116,6 @@ func (a *app) settingsPage(c *ui.Context) { }) }) - section(c, "GitHub and Google apps", "The desktop app is a public client: it needs only client IDs. GitHub connects with a device code (turn on device flow in the OAuth app). Google needs a client of type Desktop app with the Gmail API enabled.", func() { - ui.Form(c, func() { - ui.Field(c, "GitHub client ID", func() { - if ui.TextInput(c, &s.github).Font("monospace").MaxWidth(420).Changed() { - s.oauthChanged = true - } - }) - ui.Field(c, "Google client ID", func() { - if ui.TextInput(c, &s.gmailID).Font("monospace").MaxWidth(420).Changed() { - s.oauthChanged = true - } - }) - ui.Field(c, "Google client secret", func() { - if ui.TextInput(c, &s.gmailSecret).Font("monospace").Password().MaxWidth(420).Changed() { - s.oauthChanged = true - } - }).Description("Google calls it a secret, but a desktop client's is not treated as one.") - }) - ui.Row(c).Gap(8).Children(func() { - if ui.PrimaryButton(c, "Save and restart engine").Disabled(!s.oauthChanged).Clicked() { - var apps OAuthApps - apps.GitHub.ClientID = strings.TrimSpace(s.github) - apps.Gmail.ClientID = strings.TrimSpace(s.gmailID) - apps.Gmail.ClientSecret = strings.TrimSpace(s.gmailSecret) - if err := writeOAuthApps(apps); err != nil { - a.toast(errText(err)) - return - } - s.oauthChanged = false - a.toast("Saved") - a.engine.Restart() - } - }) - }) - section(c, "Engine log", "", func() { text := strings.Join(lines, "\n") if text == "" { diff --git a/docs/desktop.md b/docs/desktop.md index b4853b4..d554ee3 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -21,12 +21,12 @@ The runner is `src/desktop/local-runner.ts`: the CLI runner with the HTTP taken Slack, Feishu and WeChat connect as they do on the web: a pasted bot token, or a scan. -GitHub and Gmail need an app registration, since the desktop app is a public client (it ships its client IDs and can keep no secret): +GitHub and Gmail connect through Loopable's own apps, built into the desktop app, so nobody using it registers anything. The desktop app is a public client: it ships client IDs and keeps no secret. -- **GitHub.** An OAuth app with *device flow* turned on. The app shows a code to enter on github.com, and polls until GitHub hands over a token. No callback address and no client secret. Enter the client ID under Settings. -- **Google.** A client of type *Desktop app* in Google Cloud, with the Gmail API enabled. Google calls what it gives a desktop client a secret, but does not treat it as one. The browser comes back to the engine's loopback address, which desktop clients may use on any port. Enter the client ID and secret under Settings. +- **GitHub.** An OAuth app with *device flow* turned on, in its settings on GitHub. The app shows a code to enter on github.com and polls until GitHub hands over a token. No callback address and no client secret. +- **Google.** A client of type *Desktop app* in Google Cloud, with the Gmail API enabled. Google calls what it gives a desktop client a secret, but does not treat it as one. The browser comes back to the engine's loopback address, which desktop clients may use on any port. -Saving the IDs restarts the engine with them. They are kept in `oauth.json` in the app's data folder. +`scripts/build-engine.mjs` writes them into the engine directory as `oauth-apps.json`, from `LOOPABLE_DESKTOP_GITHUB_CLIENT_ID`, `LOOPABLE_DESKTOP_GMAIL_CLIENT_ID` and `LOOPABLE_DESKTOP_GMAIL_CLIENT_SECRET` when releasing, or from the `desktop` entry of the gitignored `config/oauth-app.json` when developing (see `config/oauth-app.example.json`). Without them the build says which connector it lacks, and the app shows that connector as not available. These are not the hosted service's apps: that GitHub app needs no device flow, and its Google client is a web client. ## Developing diff --git a/scripts/build-engine.mjs b/scripts/build-engine.mjs index 804dced..f4edf27 100644 --- a/scripts/build-engine.mjs +++ b/scripts/build-engine.mjs @@ -1,5 +1,5 @@ import * as esbuild from "esbuild"; -import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { execSync } from "node:child_process"; @@ -47,6 +47,37 @@ await esbuild.build({ cpSync(join(root, "drizzle"), join(out, "drizzle"), { recursive: true }); +/** + * Loopable's own GitHub and Google apps, built into the app so nobody using + * it registers one. Public clients: GitHub needs only an ID (device flow), + * and Google does not treat a desktop client's secret as one. Taken from the + * environment when releasing, or from the desktop entry of the gitignored + * config/oauth-app.json when developing; nothing here lives in git. + */ +function desktopApps() { + let config = {}; + try { + config = JSON.parse(readFileSync(join(root, "config", "oauth-app.json"), "utf8")).desktop ?? {}; + } catch {} + const env = process.env; + return { + github: { clientId: env.LOOPABLE_DESKTOP_GITHUB_CLIENT_ID ?? config.github?.clientId ?? "" }, + gmail: { + clientId: env.LOOPABLE_DESKTOP_GMAIL_CLIENT_ID ?? config.gmail?.clientId ?? "", + clientSecret: env.LOOPABLE_DESKTOP_GMAIL_CLIENT_SECRET ?? config.gmail?.clientSecret ?? "", + }, + }; +} +const apps = desktopApps(); +writeFileSync(join(out, "oauth-apps.json"), JSON.stringify(apps, null, 2)); +const missing = [ + apps.github.clientId ? null : "GitHub", + apps.gmail.clientId && apps.gmail.clientSecret ? null : "Gmail", +].filter(Boolean); +if (missing.length) { + console.warn(`no ${missing.join(" or ")} app built in: those connectors will say they are not set up (docs/desktop.md)`); +} + writeFileSync( join(out, "package.json"), JSON.stringify( diff --git a/src/connectors/github/device.test.ts b/src/connectors/github/device.test.ts index 81e4e64..4d08a4a 100644 --- a/src/connectors/github/device.test.ts +++ b/src/connectors/github/device.test.ts @@ -33,8 +33,10 @@ describe("GitHub device flow", () => { }); it("says when device flow is off for the app", async () => { - const { fetchImpl } = answering({ error: "unauthorized_client", error_description: "nope" }); - await expect(requestDeviceCode("client-1", ["repo"], fetchImpl)).rejects.toThrow(/device flow turned off/); + for (const error of ["unauthorized_client", "device_flow_disabled"]) { + const { fetchImpl } = answering({ error, error_description: "nope" }); + await expect(requestDeviceCode("client-1", ["repo"], fetchImpl)).rejects.toThrow(/device flow turned off/); + } }); it("keeps waiting while the code is not entered, and slows down when told", async () => { diff --git a/src/connectors/github/device.ts b/src/connectors/github/device.ts index 4c6e476..8228539 100644 --- a/src/connectors/github/device.ts +++ b/src/connectors/github/device.ts @@ -49,7 +49,7 @@ export async function requestDeviceCode( if (error) { const description = typeof body.error_description === "string" ? body.error_description : error; throw new Error( - error === "unauthorized_client" + error === "unauthorized_client" || error === "device_flow_disabled" ? "This GitHub app has device flow turned off. Turn it on in the app's settings on GitHub." : description, ); From bcc3617389a5d207377a3151a6afc37e20a0f771 Mon Sep 17 00:00:00 2001 From: imsobear Date: Tue, 13 Oct 2026 22:31:51 -0700 Subject: [PATCH 4/6] Connect GitHub from the GitHub CLI's login in the desktop app. The desktop app now connects GitHub in one click with the account gh is signed in as on the Mac, which is the account its git already works as: nothing to register and no code to type. The engine asks gh for its current token every few minutes, so signing in again carries over, and only the desktop engine ever asks; the hosted service never looks. Device flow stays as the fallback when Loopable's app is built in. A connector that is not ready now says what this Mac is missing, in the engine's words, rather than blaming the build. Co-Authored-By: Claude Opus 5.5 --- desktop/connectors.go | 19 ++++++---- docs/desktop.md | 4 ++- src/connectors/github/local-login.test.ts | 40 +++++++++++++++++++++ src/connectors/github/local-login.ts | 43 +++++++++++++++++++++++ src/connectors/github/oauth.ts | 5 +++ src/connectors/github/runtime.ts | 22 ++++++++++++ src/connectors/gmail/runtime.ts | 6 ++++ src/connectors/types.ts | 5 +++ src/desktop/api.ts | 12 ++++++- 9 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 src/connectors/github/local-login.test.ts create mode 100644 src/connectors/github/local-login.ts diff --git a/desktop/connectors.go b/desktop/connectors.go index 5f3c485..790b0da 100644 --- a/desktop/connectors.go +++ b/desktop/connectors.go @@ -52,7 +52,7 @@ func (a *app) connectorsPage(c *ui.Context) { ui.Text(c, manifest.Name).FontSize(13).Bold() switch { case state != nil && !state.Readiness.Ready: - ui.Text(c, "Not available in this build").FontSize(11).TextColor(t.TextMuted).SingleLine() + ui.Text(c, state.Readiness.Reason).FontSize(11).TextColor(t.TextMuted).SingleLine() case state != nil && len(state.Connections) > 0: ui.Row(c).Gap(10).AlignItems(ui.Center).Wrap().Children(func() { for _, connection := range state.Connections { @@ -150,15 +150,13 @@ func (a *app) connectorPage(c *ui.Context, id string) { }) if state != nil && !ready { - // Loopable's own apps are built in, so this is the build's doing, - // and there is nothing for the person to set up. + // The engine words this for the desktop: what this Mac is missing, + // or that the build is, never an app to register. lines := []string{state.Readiness.Reason} - if manifest.Auth.Kind == "oauth_redirect" { - lines = []string{"This copy of Loopable was built without its " + manifest.Name + " app, so " + manifest.Name + " cannot be connected yet. A newer version will fix it."} - } else if state.Readiness.FixHint != "" { + if state.Readiness.FixHint != "" { lines = append(lines, state.Readiness.FixHint) } - alert(c, "Not available", lines, true) + alert(c, "Not ready", lines, false) } if a.connect != nil { @@ -278,6 +276,7 @@ func (a *app) beginConnect(manifest *Connector) { var started struct { Kind string `json:"kind"` URL string `json:"url"` + AccountLabel string `json:"accountLabel"` Attempt any `json:"attempt"` UserCode string `json:"userCode"` VerificationURL string `json:"verificationUrl"` @@ -295,6 +294,12 @@ func (a *app) beginConnect(manifest *Connector) { return } flow.kind = started.Kind + if started.Kind == "connected" { + a.toast("Connected " + started.AccountLabel) + a.connect = nil + a.refresh() + return + } if started.Kind == "browser" { _ = mygo.Shell.OpenExternal(started.URL) flow.hint = "Finish in your browser. This page updates when the account is connected." diff --git a/docs/desktop.md b/docs/desktop.md index d554ee3..1fbdc17 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -21,7 +21,9 @@ The runner is `src/desktop/local-runner.ts`: the CLI runner with the HTTP taken Slack, Feishu and WeChat connect as they do on the web: a pasted bot token, or a scan. -GitHub and Gmail connect through Loopable's own apps, built into the desktop app, so nobody using it registers anything. The desktop app is a public client: it ships client IDs and keeps no secret. +GitHub connects from the GitHub CLI's login on the Mac, in one click: the engine asks `gh auth token`, and keeps asking every few minutes, so signing in again with `gh` carries over. It is the account this Mac's git already works as. Without `gh` signed in, the connector says to run `gh auth login`. Only the desktop engine ever reads it (`src/connectors/github/local-login.ts`). + +When there is no CLI login, GitHub and Gmail connect through Loopable's own apps, built into the desktop app, so nobody using it registers anything. The desktop app is a public client: it ships client IDs and keeps no secret. - **GitHub.** An OAuth app with *device flow* turned on, in its settings on GitHub. The app shows a code to enter on github.com and polls until GitHub hands over a token. No callback address and no client secret. - **Google.** A client of type *Desktop app* in Google Cloud, with the Gmail API enabled. Google calls what it gives a desktop client a secret, but does not treat it as one. The browser comes back to the engine's loopback address, which desktop clients may use on any port. diff --git a/src/connectors/github/local-login.test.ts b/src/connectors/github/local-login.test.ts new file mode 100644 index 0000000..c77f6c8 --- /dev/null +++ b/src/connectors/github/local-login.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { forgetLocalToken, localToken } from "./local-login.ts"; + +const previous = process.env.LOOPABLE_DESKTOP; +afterEach(() => { + if (previous === undefined) delete process.env.LOOPABLE_DESKTOP; + else process.env.LOOPABLE_DESKTOP = previous; + forgetLocalToken(); +}); + +describe("the GitHub CLI's login", () => { + it("is never read outside the desktop app", async () => { + delete process.env.LOOPABLE_DESKTOP; + let asked = false; + const token = await localToken({ fresh: true, runner: async () => ((asked = true), { stdout: "gho_x" }) }); + expect(token).toBeNull(); + expect(asked).toBe(false); + }); + + it("reads the token in the desktop app, and keeps it a while", async () => { + process.env.LOOPABLE_DESKTOP = "1"; + let calls = 0; + const runner = async (bin: string, args: string[]) => { + calls++; + expect([bin, ...args]).toEqual(["gh", "auth", "token", "--hostname", "github.com"]); + return { stdout: "gho_x\n" }; + }; + expect(await localToken({ fresh: true, runner })).toBe("gho_x"); + expect(await localToken({ runner })).toBe("gho_x"); + expect(calls).toBe(1); + }); + + it("is null when the CLI is missing or signed out", async () => { + process.env.LOOPABLE_DESKTOP = "1"; + const runner = async () => { + throw new Error("gh: not found"); + }; + expect(await localToken({ fresh: true, runner })).toBeNull(); + }); +}); diff --git a/src/connectors/github/local-login.ts b/src/connectors/github/local-login.ts new file mode 100644 index 0000000..c887234 --- /dev/null +++ b/src/connectors/github/local-login.ts @@ -0,0 +1,43 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); + +/** + * The GitHub CLI's login on this machine, which the desktop app connects + * GitHub with: nothing to register, nothing to type, and the account is the + * one this machine's git already works as. Only ever read in the desktop + * app, whose engine runs as the person; the hosted service has no such + * thing and must never look. + */ +export function canUseLocalLogin(): boolean { + return process.env.LOOPABLE_DESKTOP === "1"; +} + +const KEEP_MS = 5 * 60_000; +let cached: { token: string | null; at: number } | null = null; + +type Run = (bin: string, args: string[]) => Promise<{ stdout: string }>; +const run: Run = (bin, args) => exec(bin, args, { timeout: 10_000 }); + +/** + * The CLI's current token, or null when it is not installed or signed in. + * Asked again every few minutes, so signing in again with gh is picked up. + */ +export async function localToken(options: { fresh?: boolean; runner?: Run } = {}): Promise { + if (!canUseLocalLogin()) return null; + if (!options.fresh && cached && Date.now() - cached.at < KEEP_MS) return cached.token; + let token: string | null = null; + try { + const { stdout } = await (options.runner ?? run)("gh", ["auth", "token", "--hostname", "github.com"]); + token = stdout.trim() || null; + } catch { + token = null; + } + cached = { token, at: Date.now() }; + return token; +} + +export function forgetLocalToken(): void { + cached = null; +} diff --git a/src/connectors/github/oauth.ts b/src/connectors/github/oauth.ts index 8056be7..c167935 100644 --- a/src/connectors/github/oauth.ts +++ b/src/connectors/github/oauth.ts @@ -6,6 +6,11 @@ export type GithubCredential = { refreshToken?: string; /** Epoch ms, already reduced by a safety margin. */ expiresAt?: number; + /** + * "gh" when it came from the GitHub CLI's login, in the desktop app: the + * CLI's current token is used, and the stored one only when it has none. + */ + source?: "gh"; }; export function createPkce(): { verifier: string; challenge: string } { diff --git a/src/connectors/github/runtime.ts b/src/connectors/github/runtime.ts index 3b38c98..c818e8e 100644 --- a/src/connectors/github/runtime.ts +++ b/src/connectors/github/runtime.ts @@ -9,6 +9,7 @@ import { submitReview, } from "./api.ts"; import { pollDeviceCode, requestDeviceCode } from "./device.ts"; +import { canUseLocalLogin, forgetLocalToken, localToken } from "./local-login.ts"; import { GITHUB_SCOPES } from "./manifest.ts"; import { pollGithub } from "./poll.ts"; import { githubRef, parseGithubRef, parseGithubUrl, resolveWorkItem } from "./work-item.ts"; @@ -85,6 +86,9 @@ function pullBody(input: { body: string; source: ActionSource; stat: string }): export async function usableToken( credential: GithubCredential, ): Promise<{ accessToken: string; refreshed?: GithubCredential }> { + if (credential.source === "gh") { + return { accessToken: (await localToken()) ?? credential.accessToken }; + } const stillValid = !credential.expiresAt || Date.now() < credential.expiresAt; if (stillValid) return { accessToken: credential.accessToken }; if (!credential.refreshToken) { @@ -102,6 +106,14 @@ export async function usableToken( export const githubRuntime = defineRuntime({ async readiness() { if (oauthAppRegistration("github")) return { ready: true }; + if (canUseLocalLogin()) { + if (await localToken()) return { ready: true }; + return { + ready: false, + reason: "Sign in to the GitHub CLI on this Mac, and Loopable connects with that account.", + fixHint: "Run gh auth login in a terminal, then come back here. Install it with brew install gh.", + }; + } return { ready: false, reason: "No GitHub app is registered here.", @@ -214,6 +226,16 @@ export const githubRuntime = defineRuntime({ return refreshed ? { account, renewedCredential: refreshed } : { account }; }, + // The desktop app's first choice: the GitHub CLI's login, in one step. + async fromLocalLogin() { + forgetLocalToken(); + const token = await localToken({ fresh: true }); + if (!token) throw new Error("The GitHub CLI is not signed in on this Mac. Run gh auth login first."); + const viewer = await getViewer(token); + const credential: GithubCredential = { accessToken: token, source: "gh" }; + return { credential, account: accountFrom(viewer) }; + }, + // Device flow: the desktop app has no callback address, and needs no // client secret this way. The attempt carries what polling needs. async startDeviceLogin() { diff --git a/src/connectors/gmail/runtime.ts b/src/connectors/gmail/runtime.ts index f072a30..e7762c5 100644 --- a/src/connectors/gmail/runtime.ts +++ b/src/connectors/gmail/runtime.ts @@ -65,6 +65,12 @@ function messageIdFrom(url: string, payload: unknown): string { export const gmailRuntime = defineRuntime({ async readiness() { if (oauthAppRegistration("gmail")) return { ready: true }; + if (process.env.LOOPABLE_DESKTOP === "1") { + return { + ready: false, + reason: "This copy of Loopable was built without its Google app, so Gmail cannot be connected yet. A newer version will fix it.", + }; + } return { ready: false, reason: "No Google app is registered here.", diff --git a/src/connectors/types.ts b/src/connectors/types.ts index 50d6e40..b8bc60a 100644 --- a/src/connectors/types.ts +++ b/src/connectors/types.ts @@ -526,6 +526,11 @@ export type ConnectorRuntime = { * callback address, which the desktop app prefers. */ startDeviceLogin?(): Promise; + /** + * Optional for "oauth_redirect": the account a tool on this machine is + * already signed in as, connected in one step. Desktop app only. + */ + fromLocalLogin?(): Promise; pollDeviceLogin?(attempt: JsonValue): Promise; /** Re-read the account to prove a stored credential still works. */ identity(credential: unknown): Promise; diff --git a/src/desktop/api.ts b/src/desktop/api.ts index 8a331a1..36eaa3d 100644 --- a/src/desktop/api.ts +++ b/src/desktop/api.ts @@ -3,6 +3,7 @@ import { toDataURL } from "qrcode"; import { AGENT_MANIFESTS, fallbackAgent } from "#/agents/manifests.ts"; import type { PermissionMode } from "#/agents/types.ts"; import { CONNECTOR_MANIFESTS, connectorWorkflow } from "#/connectors/manifests.ts"; +import { oauthAppRegistration } from "#/connectors/oauth-app.ts"; import { connectorRuntime } from "#/connectors/runtimes.ts"; import { dispatcherIsAlive } from "#/dispatcher/instance.ts"; import type { AgentOnRunner, AgentView, ConnectionSettings, JsonValue, LoopEdit } from "#/lib/domain.ts"; @@ -199,7 +200,16 @@ export const rpc = { */ beginConnect: async (data: { connectorId: string }) => { const runtime = connectorRuntime(data.connectorId); - if (runtime.auth.startDeviceLogin) { + // A login this Mac already has comes first: one click, nothing to enter. + if (runtime.auth.fromLocalLogin) { + try { + const connection = await saveAuthorizedConnection(data.connectorId, await runtime.auth.fromLocalLogin()); + return { kind: "connected" as const, accountLabel: connection.accountLabel }; + } catch (error) { + if (!oauthAppRegistration(data.connectorId)) throw error; + } + } + if (runtime.auth.startDeviceLogin && oauthAppRegistration(data.connectorId)) { return { kind: "device" as const, ...(await runtime.auth.startDeviceLogin()) }; } if (!runtime.auth.startAuthorization) { From 55ac4a5d443760a109a4665c47326bf53d97094a Mon Sep 17 00:00:00 2001 From: imsobear Date: Wed, 14 Oct 2026 18:59:48 -0700 Subject: [PATCH 5/6] Find Node for the desktop engine in milliseconds, not minutes. The app looked for the newest Node in every folder a version manager had made, including fnm's per-shell folders, of which a machine gathers thousands, and asked each one its version. Starting the engine waited on all of them. Now it takes the first Node along the login shell's PATH that is new enough, as a terminal would, skips fnm's per-shell links, and asks a Node reached by two paths once. Co-Authored-By: Claude Opus 5.5 --- desktop/node.go | 30 +++++++++++++++++------------- desktop/node_test.go | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 13 deletions(-) create mode 100644 desktop/node_test.go diff --git a/desktop/node.go b/desktop/node.go index 8699a69..906101c 100644 --- a/desktop/node.go +++ b/desktop/node.go @@ -25,12 +25,13 @@ func homeDirs() []string { filepath.Join(home, ".cargo/bin"), filepath.Join(home, ".volta/bin"), filepath.Join(home, ".deno/bin"), filepath.Join(home, "n/bin"), } - // Version managers keep every Node apart; the newest of each is enough. + // Version managers keep every Node apart. Their installs, not fnm's + // per-shell folders, which are links to the same installs and of which + // a machine gathers thousands. for _, pattern := range []string{ filepath.Join(home, ".nvm/versions/node/*/bin"), filepath.Join(home, ".local/share/fnm/node-versions/*/installation/bin"), filepath.Join(home, "Library/Application Support/fnm/node-versions/*/installation/bin"), - filepath.Join(home, ".local/state/fnm_multishells/*/bin"), filepath.Join(home, ".asdf/installs/nodejs/*/bin"), "/opt/homebrew/opt/node@*/bin", } { @@ -82,15 +83,22 @@ type nodeInfo struct { Major int } -// findNode looks along path for a Node new enough for the engine. +// findNode takes the first Node along path new enough for the engine, the +// one a terminal would run, asking each candidate its version only until +// one will do. A Node reached by two paths is asked once. func findNode(path string) (nodeInfo, error) { - var best nodeInfo + seen := map[string]bool{} var firstErr error for _, dir := range strings.Split(path, ":") { + if dir == "" || strings.Contains(dir, "fnm_multishells") { + continue + } candidate := filepath.Join(dir, "node") - if _, err := os.Stat(candidate); err != nil { + real, err := filepath.EvalSymlinks(candidate) + if err != nil || seen[real] { continue } + seen[real] = true version, err := nodeVersion(candidate) if err != nil { if firstErr == nil { @@ -98,18 +106,14 @@ func findNode(path string) (nodeInfo, error) { } continue } - major := majorOf(version) - if major >= minNode && major > best.Major { - best = nodeInfo{Path: candidate, Version: version, Major: major} + if major := majorOf(version); major >= minNode { + return nodeInfo{Path: candidate, Version: version, Major: major}, nil } } - if best.Path != "" { - return best, nil - } if firstErr != nil { - return best, firstErr + return nodeInfo{}, firstErr } - return best, errNoNode + return nodeInfo{}, errNoNode } type nodeError string diff --git a/desktop/node_test.go b/desktop/node_test.go new file mode 100644 index 0000000..9a63f0d --- /dev/null +++ b/desktop/node_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "testing" + "time" +) + +// The app should find Node in well under a second, however many folders a +// version manager has left behind. +func TestFindNodeIsQuick(t *testing.T) { + start := time.Now() + path := enginePath() + node, err := findNode(path) + took := time.Since(start) + if err != nil { + t.Skip(err) + } + t.Logf("found %s (%s) in %s", node.Path, node.Version, took) + if took > 3*time.Second { + t.Fatalf("finding Node took %s", took) + } +} From 94e43fe9abe92d3819031b266078b472e99691f4 Mon Sep 17 00:00:00 2001 From: imsobear Date: Wed, 14 Oct 2026 21:24:20 -0700 Subject: [PATCH 6/6] Draw the desktop app's icons from Loopable's own artwork. The app icon, the menu bar icon and the sidebar now use the Loopable mark from brand.tsx, not a gradient ring, a pixel ring and the repeat arrows. Connectors and agents show their real logos, copied from the web app's connector-icon.tsx, in place of two-letter tiles. Co-Authored-By: Claude Opus 5.5 --- desktop/icons.go | 95 +++++++++++++------------------------ desktop/marks.go | 23 +++++++++ desktop/resources/icon.png | Bin 87043 -> 46628 bytes desktop/shell.go | 2 +- desktop/tray.png | Bin 0 -> 930 bytes 5 files changed, 56 insertions(+), 64 deletions(-) create mode 100644 desktop/marks.go create mode 100644 desktop/tray.png diff --git a/desktop/icons.go b/desktop/icons.go index 94ddb78..34e2d3a 100644 --- a/desktop/icons.go +++ b/desktop/icons.go @@ -1,12 +1,7 @@ package main import ( - "bytes" - "image" - "image/color" - "image/draw" - "image/png" - "runtime" + _ "embed" "github.com/egoist/mygo/ui" ) @@ -37,70 +32,44 @@ var ( iconOpen = icon(``) iconPlus = icon(``) iconChevron = icon(``) -) - -// A connector shows as a tile with a letter or two in its color, since the -// web app's icons are a font the window has not got. -type tile struct { - text string - hue ui.Color -} - -var tiles = map[string]tile{ - "github": {"GH", ui.Hex("#24292f")}, - "slack": {"Sl", ui.Hex("#4a154b")}, - "feishu": {"Fs", ui.Hex("#3370ff")}, - "gmail": {"Gm", ui.Hex("#c5221f")}, - "wechat": {"Wx", ui.Hex("#07c160")}, - "schedule": {"Sc", ui.Hex("#334155")}, - "codex": {"Cx", ui.Hex("#0f172a")}, - "claude": {"Cl", ui.Hex("#d97757")}, -} -func tileFor(id string) tile { - if t, ok := tiles[id]; ok { - return t - } - label := id - if len(label) > 2 { - label = label[:2] - } - return tile{label, ui.Hex("#64748b")} -} + // Loopable's own mark, as LoopableMark in src/components/brand.tsx draws it. + iconLoopable = ui.MustParseSVG([]byte(``)) +) -// connectorTile draws the tile, size DIPs square. +// connectorTile draws a connector's or an agent's logo on a plain tile, +// size DIPs square. The schedule is a clock, being the thing itself, and +// anything without a logo is a plug. func connectorTile(c *ui.Context, id string, size float32, dim bool) ui.Element { - t := tileFor(id) - box := ui.Box(c).Size(size, size).Radius(size / 4).Background(t.hue).Center().Shrink(0) + t := c.Theme() + box := ui.Box(c).Size(size, size).Radius(size/4).Border(1, t.Border).Center().Shrink(0) if dim { box.Opacity(0.5) } - box.Children(func() { - ui.Text(c, t.text).FontSize(size * 0.4).Bold().TextColor(ui.Hex("#ffffff")) + if m, ok := marks[id]; ok { + face := ui.Hex("#ffffff") + if t.Dark { + face = ui.Hex("#171717") + } + box.Background(face).TextColor(t.Text).Children(func() { + ui.Image(c, m).Size(size*0.55, size*0.55) + }) + return box + } + glyph := iconConnectors + if id == "schedule" { + glyph = iconClock + } + box.Background(t.SurfaceHover).Children(func() { + ui.Icon(c, glyph).FontSize(size * 0.55).TextColor(t.TextMuted) }) return box } -// trayIcon draws a loop: black on transparent, which macOS tints. -func trayIcon() []byte { - img := image.NewNRGBA(image.Rect(0, 0, 44, 44)) - ink := color.NRGBA{R: 79, G: 124, B: 255, A: 255} - if runtime.GOOS == "darwin" { - ink = color.NRGBA{A: 255} - } - // A ring with a gap, and an arrow head: a loop. - for y := 0; y < 44; y++ { - for x := 0; x < 44; x++ { - dx, dy := float64(x)-22, float64(y)-22 - d := dx*dx + dy*dy - if d > 11*11 && d < 17*17 && !(dx > 0 && dy < 0 && dy > -8) { - img.Set(x, y, ink) - } - } - } - draw.Draw(img, image.Rect(28, 6, 40, 10), image.NewUniform(ink), image.Point{}, draw.Src) - draw.Draw(img, image.Rect(36, 6, 40, 18), image.NewUniform(ink), image.Point{}, draw.Src) - var b bytes.Buffer - _ = png.Encode(&b, img) - return b.Bytes() -} +// trayPNG is the mark in black on transparent, 22 points at 2x, which macOS +// tints to suit the menu bar. +// +//go:embed tray.png +var trayPNG []byte + +func trayIcon() []byte { return trayPNG } diff --git a/desktop/marks.go b/desktop/marks.go new file mode 100644 index 0000000..bfe1b45 --- /dev/null +++ b/desktop/marks.go @@ -0,0 +1,23 @@ +package main + +import "github.com/egoist/mygo/ui" + +// Each product's own logo, in its own colors, as the web app draws them in +// src/components/connector-icon.tsx and copied from there by hand. Service +// marks are from Simple Icons (CC0); Codex, Claude Code and Cursor from +// LobeHub Icons (MIT). GitHub and Cursor are drawn in currentColor, so they +// turn white in the dark. +func mark(svg string) *ui.SVG { return ui.MustParseSVG([]byte(svg)) } + +const slackPath = "M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z" + +var marks = map[string]*ui.SVG{ + "github": mark(``), + "slack": mark(``), + "feishu": mark(``), + "gmail": mark(``), + "wechat": mark(``), + "codex": mark(``), + "claude": mark(``), + "cursor-agent": mark(``), +} diff --git a/desktop/resources/icon.png b/desktop/resources/icon.png index 383c77a040a1226c14e8677d37efe01fce16ecff..7ae1e92b1afb06397619ff1025ae8537f00b39c2 100644 GIT binary patch literal 46628 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

    DI*^yz`Xl*FC@s z@AJHi`JW5KtI?9mp>rX|&bvqBI@nQqw8m&DK19BsYbkkwRnfd?R{ZL6z zAJP9Ga@B172~#>?X`_B|l$XBs=~QHE10=2tSYQt$defrN!-~y#n5&e~?L*4|j0CF_ z6Re8Gw?8pH<5e)k2>dG%gX4%e0c#d9K_$%B&V6-+(Ga^=_4^%|9kzVwF(8e0dbK>@ zppG$jEke!6I?P00is;`pbC#AQ2c?$1#KkS&Ik<555!Iab4Nf6#0w@?z{$O|1T(a2b zG@;gWhHfZzWwIyanok^8zW;FIYr{0imSc~&ZF8h4`jRQzSU!^^EfxX+*Z_i)G?W^g z`u4tR3R{koCr4c1kUKF1tUutFo<&ERyA3PTQ_3e6!82y!G*s*5!qL z=22%yd8>*o+V}=N7n=J`UmIk^X)l3AcJyxz#H9MXcJBY7HXzY|o))UZUb9G>Q)W$j zVPH^^WE3I131P`2$adOm-4TZd0{?qZ;u}##xh|)cv(DWCJGhg;b7KHg8Z!obhP_sF ztc;%SJ^B9fXzV2Q|C08grqbUqoH6S@SZf-n1l>iZRJ6=&BTCSu&W!=WR|znVnorP$aR1mI7@BPrq3k}?iKaH;^TNNmK zPz?`ujSBSTbk$|&9~KR{Tj30`=Fy}-r{Om&E&(JNhq`iZoqxt7qXjdG(laH$3QWEL zkrE5RwowCvIabd$A-AiOdOXQ%qumyi$?R&|=B0pQ_C0&FqbaY1^>mkjR*l!~R_}z0 z`zRus!5}V)S=?c3wraz6GHKu3lPAKPs`WlFl;P*|CRzoi(Ib5(o^eU!i79N;DQGI< zkqHa1$%Z)bR#^>xrC?108`}tt^^Z|IEa5;sUhruFGHNHF@_QB0%1+(6ohWo4kU|0# zC}NI{!o_2u=UH}!PWOIb{aoWqPmrDc&62h{-GvU&1tF~&_?vwjuU30hq&HzRJ1YI}f9oQ%NpaGQgSEXYuAQH6tNzgzF zIF=$$-g!jQE`nlu5TuU#nC!AN;LxhmfzvjJ0^$K2K>|tV&#?8gZg4uBt!A!&?6`fV ze(7}*56BShFr1PBFFhps=^W}Z!S8qOy+sXh2n{l5#i))BJEl!^r3(-`q4fwO<*Hji zHM{41C@VgeJ9g#z^FF@@rz^MId*@hnQN+@9Rkj)vypVPd5us6RhMq2hJ@d-wlNJT$ z@as^G59`&#VWvP;n0k2wV_uG{5E>GYE#&5EsGg?r9RzHH;jBCki7RxwsZUHM@4r{K z6V_967W}T}AhfRY+sUB)3a+XHFYRi>(rv~^>)6-hHVfY5HStam|{}O!iEa^`08MB~~eTZ-pyA!U`eW@Ly@zUt9ITXI}u&UmPxybe%f}@edI+@vu zJEreFSAPWHpPOF%0fDS&>c@b8gSvqmS(&yW^c>&-JvX_2`-*wGTC}uZ@yi!4#yZp$ z3bmSjAe9za_lcE$0upKa?A6W97?!JmP+F$?WpWzH6VXcPfL0{=3n) z0!t06VMl4okm=u6xNdM_5#wUImF|TDb*d64qRB8bVe0u6SCI(A5nOWa1t9;uv^-M& zRn`2=@6hwwAhbOPo1}bfg1yhYHAc9` z5~hQ@HyW>WyltX<(RT=7-uK?fXX^$ctztMry=Tv!vH5!5oAA;Ijopdyt)zU)2PRSN zXdm&{LpFb#Bh8;JS1-1!2u%dM_|n+efq4)#6&r0-Yr3d$sz*>P4q>`AdK})z(wOkc zwtTwi+_HG|-Tcv2h@C|sbYE<=J6Xq_0YfYx--qf&)TA@ifY2MZEh^Q>gSWFKbK6y1 zgGk<;*8_`7ylLdsHL>cm_UH9|{_vl&-kYvgw-h**&X+|wwqMeEQu!U`&cMU6;S>x& zY{H{n?2LZe&yfJsl(;O{x6NBUZ_wRX?BjnQVBx2U{J)pzA|g~?9@SzH`D8?>lG1#6@d4pVLCn|z)^C$ zd9}-(GfAozAn!m%ONM+U$Q@CWvGz2QMiz(KXsBiIJ15?mCh!X+FuMR1{a$RRppNuS z!L0dO(IP@Zdj{;tn`>Ih{ZCUsBRY%t#AV*f!t?tfd%tK_O$yOr9hoj3=0GkCWP=$t zp|9tH+wU%YiylSL%(^l@bR$NC=(V(*)hYbyho<0Omlz_K^Vjr>cij*`$0hMv4DyhRrSNAxY zw`xxaEX=wDDe||enJ~$&YVEUb4LiCT2m*J%y zmo=Em%R1mbb`Vp5;oS-hZchuZOWy4~%CY4T2uWf5F@JCl$1%O!+y3ZKz0jL8;F@b! zARaQO4i?>~+5QOe8x5y^oPo>WjfRY_^qZ4qlUW#b&las(J%^r4y3QF#-}}7ppP+c* zBYSsd(;Ugi> zEXdnJ>#uJGaf)^~gz@j2r`vD#w<>ZGhUYzRX(YYYVVi@Tx*M&s1E2E@xB#)zI-TlHz1e){oZ2nikl zfbP=<+^4PC(5R-+-TqV!fp=-3qK<)7wg;X7?pEda?~6eGn9(Cl;eEPJb<7bE!3qjU zwh!Nd1v?Jht7kx)k0szWuwb2?{nmm0i@H^wrG=TpPxpya6C|3JBj6R!-S5TEK37-R z(uB99cA`}VZ0Vnb9yvjdN{@>pD#rqeHQboWXN~qD_^&UH$8+13oPFH9xdU`Gx>Y?? z;{pdZ_cBPNtU-5;cYGcY$`ZO4W#E8?&eOU$GT^rkm z&X$fIuyV3a7y@RR0W3%xPK1Nh`Q^)(+>2zrVq4)ue?fG<3dJ)u@+N{mi4K4^bQi>| zP+h1I3OIVig0^ybF8dw*KP{k=H`Lv%#Rp1f|2lA=R$cB#=p<4gK!RLgksc%L;5wzO zoOxK~l|@77K`sUF3X~9|GfipW583m)2j-|T@du4KK{&eVf-L2@wba9ux&*S2&e>O> zgEA2(rmHq)%BJ!fsYj!{mis`u4@WhPnCVW5&&K-aUT9UI=e&;FK$ad1UT_ zpLB2fNYzip8Fy2SRN&{tvh0y;qvK_$XUz{vfP(Av!=G~kNb+vd;yxMYjJrKq!cy5v zAqTpLSS~h^H zIf5i;1qw1g^bCl4FCVe+q+txdQH=^_QRe)g)tA%*@(*~g-ZB9Gxy_7Nk#MsYZP&5IDd>c+5gSn#5Z*fppP~ z4gjPAEDJe6W7Tq?;A3KxPd29Fe z=sWBH9N|}GGlqEb6Ni(RnFg&6PqMxU6+*XIkXXc8*7lvilkg)v#e>e=XAzwN=pkPl z8{vlO`nAEthwc^;V_~~>?Y_m;mg0>YDO|`^9(JJ=ghY8i0wv#EL#la#SA5EFMZ5jqy-J6_WtH zJ#S9ldH6dT5}y`?QQl67U7hju$?Ti9B9~OZxI;O&^zA$=18F6KMnmAr+e3$vZ3oM+ ztOtg9j2R{$%6N*gWDZykFM-M%tZ% z=JCtd;>|2`yYo3}=6-x!0!l_7D8A3anu(sk4LBQm1M6rzjkjM=9@t!4aQYc2kXBNn zjW$Pt9CkAZM8&}yIy%Y&SWCit3&vsd7_gP|3kcL-HoEW=rgc;Agve>Z`z$*VMYr(o zOF$Z_$+x7sh)j#-;Un-T2lR81$us_7sdJfn#T>g901$O#1U6ykcoG1Jb`Q0K4e>Up z>h_&L~228(g-55ng+hhSy{L>)w=-F;#lI2bE%+#qhV^xSN)EyXDO2e&uql}?`u?!a8VXJ>X~z^q;KuOP-K_Obev7n?hF#dhTtMU`R4p7oC2a?!{x0B^8-TbZO=gv=Js!qNA|io+ z>;)kP^c$NDT3cH=e=c<0G#dZRZMO#<+E#u+4G^gdCQ&ThHBa4jc>EpV8U_MBTEHLqSJ^xiZy%z|M9_3EwVNJP+q zVWYzCTLf@KR9Q(I8u(K`P{nbJj-QYl()Q^A~W$q$@G9I=r59zHE9h+FtN0 zScVaaJp;o(7001EEnVF-j5ebY4zrW(@X{w!E^JK|g<4{8@}h4&8&s`x)|de7N5e#o zY;&QG#kl`DYv4j5x&;;oQ3Je2j>IN_oU+{(|GqWoHX4mb#cor%_4I1}#vEmG0OvF) zLJ^?(^P8x{&#x^gYb#G7G zyhSycLp~HQug&@TbB<<=#8`PRFx5HicKf|of1Go~U}%K&-GMlSrilZi?6d4BvM!P*;VU1CCgR;jpvygFWA&sbeJ!&EdY! z`sGa$%waTm9LEQm(4E-bZv5BZ7g8(&g;oyC4tWk6keC2OW#_xY1>-INUWCl$VILnY zh2UDD_n)+MQJxOSAqQMXa0l}sAL+_WE^T4bVjT)) z{{|K~8yry@Th?i_$#|^m@OWw)XI#(TyLS>^d^0YhGEvb-<8|zG#Qw#}o>pBd^aI04 zDABS|jbIG4_w&&E6c{}MlFW~kIl(#F&1y)vb0+X!G6!<~0`JtGa-PyYm%|URYMa#A+wJHn@vdC4*E zU*8HaA;Tr$_gIa2&J|k^Hf4FN2Z{}FkITK!_wBsnD}=mjPLUe0G@n`2e;I||#V*_r zT{^Rib-jE{B;KZ|Ga8UIOWB4%`=mjs7#aB**1xxpidB|?=9uyj-!Hqv|1B>OoH853 ztloCeyR!Y6I2qCAPf)RYo*!0uC~N~b+&!#{ln;aXu0MJ4xuNN&5Bz}}vvQ3ruOHwY z(*U`6k|rPQH1Q3EHzs0*0`(l+!we1+8!*X91hS=pLN8;hRE5$K`s9MvmLDE6bSv*g ztIzs&%|Q}jFDd}k2QY=Amh|GJ1BHo+$zM2P=Ls|?xSRUZt0pc4W$SK~0OKC$SU!dx zdg~yE>uj=;vayKNEptrGNrd(UJ-Uva(TjtKZ3qzMD4HvWz9gd_)=r>CsaUvPe$(~T zrQZ??xSv07SArsdN7Wuc-5&}^IH9KDt4Ns!+wFTuKG3%=y85z@$^&8%uF@;82&-EC z$@K2{LUI1~8Eax^e0c8`Ylwd~irdvwH`jZXj+h1k8M=!a6)gfX-USE5BWp3x60yd5 z$`e?g=0WUZ+Miriu)Z!rFF~bB#z?@82!cBp3Dsc0Ck5}hICmR zl9rQm^}9`RG+YCn{D3fW_2K-vVMx_S2wndc7JzZ+rNU4FWk0-zRtStJ5&n{imfLjN zjr5G2y-;}QCZ=ND1>F++TQ8}U6IX_Pi~zYbA-b=AF=_V#B8W!<5PBmeFxz-7Wdbst zmX1yYN-nhQxCrf#Z{X(dV3sl6wDDC@b+^ex-Pi7uJoylKYM6X#*7W8UO&)L&(VAms z9-6zUdtqr0FPrg@u$Czr!BBd?5^c?^As|E@1$8VU4Bvk>UjaW?vtiVA%+@`7ZngpEBtb^QJC z#gAYZ;IUsciy;Yhm)yIPeus4V|3}w*$5Y+@@#EJqvQi4suo8*vq*RU)Nh%Sdj53RG zCnM{eil(9@gh;aY-m8JEkiA>7_vW16>r(gU{(gUt-|vt6@wgv3?{lvAHD2TSe7;_n z1zi>>UlVUW@!|xAB+SDh=otk}`)5{-iVt+i$aB&BbeMt;X=U<0`S?DoFQuO)qC=Sde08jiP~LbX3JY5v+-KqqHrcFQ89&tU7E=LFuRyKI z(;?`;q{*2;6*Pisj8-_SCPmI5U6Rssq9n*tDG=s5@A|^Jnz#RA0ZYNP^&7mD zk;y3y+P~XJ8O{QT0IYSaBNQ@HTfvFPVS-~Zk&(N#d=bb1@A!p8WDo2WdMbc4 zo1yYE3w$Wy4aPT$7QQ_$=AQNaurl&%$9jk~lwyh073~bm`YoctN$`Su@C)8UN;*+g z!58{Gy-}qQ%KX$%OEQiRcCi1*srMVqoGzSHGIO~5)I-fU*kCj$21j(NTlIlnx5%XRM;xC0DF#gURnzE8&D#h&$p5-$F5Snkg zc{6?q5jSA8BhGUWqC?Rf=b5_MFmBO$~;5|z(%7J_6}Nckn6E1ODaC2n(U9l{fAp9ace;OW!z{d+Soz`NhF z%?#2r-6}Vzq;EQN=rl-2Qu}-al<)n{{r)szYR9-N>1%a$H8t<$6>W=iIQCHtFHDKG zOb5+CqV3uL+j0Q2Fu+tP1Ky@dETwR#&6RBT0&o%ZB@wGn-y(99omzfB}rmg<$u>f=vc>b)3`zxi}q3 zYH$$4hFJ%=wCt*$3k~&_5ak0DWo}KG&5pEd`&rb#PJ(clsd7Jv4WR3U1jn9u)sur} zgFoi40UMfHm8hTL;=34o_`8qn@0FNm2V39Y-skJ2vl`Fou@a~eTjt? zBLcMO!4)t;H}C8(0PZ-+htVZGZz~BDHy0KpYin44LP(diMs3SO8jbOfa$y{Wu1RRy zz5bnzEn3lUZ%(;2ET)+KN}S@IK5+~3)rQ4-h9ih!$p#pSAeRq?>rxS05{5A*mKD=v@7UiJqO%!+^xVI zK~t2&0;{0$$i(_jpcZt)=HEFFV=)m<2QK^L{0dZ{m%B*AqlHfuX3sZQ{n3zoa@VY3 z_!PTQ02wjGg-R)Yl+}Jq_ZLAmrG}b8<4m6Do zgQ!V|XAA_S-lB?9{puZ8o#VUXNEmo17lsJ7rO>R$w8s`IwERKCyVvYxRW{&)isPqz zb5is(K8dECg!J^AVo70&RsBs^dqieYDKKd|Q=mP1W_3G8R`vs+=QAIa(^>nO>O--7 zy~qRViaYb~Hg;6e#GcL8yG&5fzq}wvz>v|~VEl9~6 zTmHW%$sr*rd-MLd-(C~E)3zP}!M&Xx-w8sD)GZIcdkl>(rJGlKodzz5ZGJIhSz+j_ zw$A+m`03y;8`le&i=K)p4$RwHCj3h{VE$!JHAE>aV>0%ANGsdC7TH5C^oU{Z{((cR$o#@1vB-Z2DhlA?GI?&Lm-($GJscCQ( z1aDra!Hq>j8dU8XTG>lEUJTaE|LSfia0Y=?usvU_T0Qg^kiom@CPjXam>5gv;=qyL zPv*j$Iv&=QJjQa1Bd#+r&W1Vof5re%uv!N^4E2T6ep#iq0EvzcHO2UH+CQDQD+82AvsG#!^W;J!-`6Iv~FPG;w9(F5tLy^D=^w7CakVnlMZQA?+EzEdD`= zROroEWDvHnflORU@1itR20zT3P)P5-tVim00ny*j4)m_pY-7c;8Q$sQS))A9RoBb@ zu&N?^GTwRN-^DJ18#G660i+jE!!gPR;);ElTTRCXU@Y0Ye4m{gcLlID6M%ZAMqqp6 z{Je&2iclqPL7SEwq{D z;VE7jM3w^KNYs5^S2aK3v#%2&kYwy}0Q6+|m38u_uKJbNp8yDRsRxiD0SqH^BjCYu zjzi}O?RWmlwc<}y$7(z0gIR!}LuPFzd+xpH=xbun ziLY3&^^+;tEW0-1%n`%c<0T9PvU-AEO0w3#kzZl1P~hY>@&!(~sAEq>PaHb}LxfV@ zC@y^GoV9s^Pk`j`vk}q^N%>) zeQ$c+GH!TX<%G3)5?o@}R1b|-W$s=+f`uqHQ;@me^LRaP;*`TcL%<1pLfo4E&NzBrMrVYWOm%*#W9GN;;Rf@$KIfl?X6XEyo^2P-O1)MA zeJK}){x7ZYM&2W(wQ)<9J_1-?v&M5XCV z3otHe#^9;f!)ZP4Mg-riB!jUYZfztA>Ow=H05_-8mWYkx#5xs|o6pOL0J1~)1JaxcjPyrSdg+Q5R@XlhaQZ4WfsI(x6#_!02;VnH-*m#|W$i;8{76P506ucB3c}>fW zn|r(Kqo^6I5zcb9T^HIQHu&%5VD#4^Ab;IwyYjT~2~xs<$n&FsCX@%!XvTr&?T&Lp z-pvvKO)pGD!!ZzHV`Rhtuitw3AT*~_5|X8d9##r>z2J`Va1dsYXucv)g{t9!19>?{Ir&uO5)c@ z6_&CB_zvM*cI|zIPxj~r;0eJg;Yn%1xA-OOLO_ig;K|}FUE+@=&=YH1-rC^y;w%GMSiUNIeiBQs1f9V)cLoL#}hp-DxU)l5*O68Y&8BfjlHvk{F3a_tu zXviM^`gAdsX}mRMs#i8(IYQYoΠJ{h#B3t$hC6xo3UXS+?xuAMK*A886*i z!)Rv>2tA1CyifmcCLjUiY(gt#K8G`eOnwD9P(jYd+|&9-nc>am$B5?dbPcUc z>~`q|xGw=CBb7e#Jub?Se-!n3fI5!rzqh##BD->7a7k<{{lF|}1I+1X^eeO%NS!-G zHWWJp<{+KINRGl=FcYv6rjVQCJ%$qu{`3>Tq%#eg*DZn<-Mv5yXJZvo9~!b5{^-6I z0-QKna_J{}$a$!s34{)dU!cBUA?z=dyJN z-YZm01VT8%5>N>_F&*A#syO#UD}#WEwLFpU5jjwun1;(G87VlQhr$%t)@uU=gcNX{ zwkQDLpz)3B+!r8z{`3pDL&a(x_T6VVLNC|7I~X$&4#9MK+_9jN3^Q)RT7r90Y|oy{ zj^Ef09y|!RIK}K&e97de?ZNM;3&xMSIS7Kmh_hwIlWN-KRu~i}|pwK(ju}p ztf)O5fgBH8F9uiUQRVYYe!tA_=;5VszsdWE`BL5Omnm z_}~hYn9v)Kz-{k6F9!;?)#YAl3v4fX?!D*&ClEacu-vFZiWM&*+h?C|SYZJqb=afF zkAFJ<+R><$$I{Fu(cm!Yzf%YOam3TMqjFr8w3a7*`|xmT`?PFGcy}mmgo3Q2sJy&h z>pDLj~7c~g~<+o;g_ zS&(G?`&><$?}~>Mfta7&`>Zp2$_Q0wF0G#qHqwbIdxvgSUA}VuP{J0ECJY_ z6UhE}<)K(!T$&X*1@Bt~A^ac~+JemUt&KTl{Fmi{elPa6k{#I?<^4sawciGIeGTgK z?_vG$zH27j<=exS-;1zc!Z|zFy`sKdEO|xESS)G!=V&1=0C)q-g${`Gw{3c$xlvq@ z=b(UjuAqEoPrQ!Uem50e**)WlV}=3k)FP0n7+|sA@u2!&|*W+1gyWa}5f2bwQ*-w#kNGv>VDI_x=z`(%pFE zv+`%P`{ip@V}MjMfJaxS!oJD`gqkTNNIVHEKe&wD&nfM!8TedCs<}LOQvN7nUyl*v z#6G3`;D%M8F0VC4oy71y*(^9t>n0^(2?j8@LTkSRPsK72P+->_N(l^Xa71hF+=l2j z<0;ezjuuYWbwH2xB(jpIqO%3Z6r@iLptMT-4(f<(%0{w(jT^U)^pkZ;q8PQy#Z^Wv zG$yf`AajU?Fi?Y*{1ytY6F1}?OfFu!1}dbp;s%+i8!}$Ge*FIEeH8nBnPB=~N3K2v ztX+T4iiJQqR?Kwz1~=|d5)D>*$@DxDU^Ch^E@21gont>oWxrZYGIkvlBE;cX(cpgY zDONLNB19gi#aOUG-HIkCz$9`9WcL$MRqrpiyo zj9^n$<9uW!^*!jhEM+2rb?}a_>8%4g<=>C&rQ_~_x@)g?#ocwNtmptj(p$U;@Lyn*a5!L&SrN=Dv~33A!-@@D69fL>m=0LcrgCo( zru?CEq*?h7XG}Q)MMDowq_bBK4aL6o5(kTO`Y-Lo!9uvF2WoIu-owB!%L6K#yOy3m z1kRYe?ZRro5IL*YB1%Kw;)*=NEZuJaqF)yfm}GosHN=m+nj7v?@y1;pfQ zwjM0gKxz!Iks7an*<9+SQg(YegMtGaRUkbrW^l7V_HyVe$H@ZG5tcqQTC9SXZ0XYQ!FS2n!WKmCu5@l7WX%Sm?X!{kz9=`TC!* za*dWZ4O+(*(yl&?rFAH`8^W%=ALgJ*0}GPDe5ns+O!Voh?qRMScpnaw+bnQ@Mw?6lgpc1r%WuGQ(WJR%mY&ME{J< z5RPh+A;d%1>kdzAtWB*gh+JKP6#py5P!%^W4!x-sg4sUUSbd`4CDM~mKe!GKA2eDe zAhE#K>yz}(D;rs8XzW8kA#;E5;_?(Ms`-G~ zjNhOT0&N9$om=&^v?8JIm2qxOfeI@G?^GlA8zy+m$mL-ovONluA$AzbGq8nPlfOU} z)CSP61X*iBO6lE(Lpi9u)<=EoU_o=sL99Ik17(?;8Sgc|%dl|^iVDe_3$kV#xdkWc zJeF|V)55}D`hJ^%mu;i~3N3<|K_;QG?s?u^DX)L~9284|7UvGcSFH;`oy-8V$v7v) ziInmU0b*>)Vwek3v4tL7@I@^6i_Edw&_kGY9v3;Gs1B^F#z$c;ZGjKv2mofFgBKr) zpUg?{||aFN^}`H=!<-%N}liOTXk24z+dD}W8aYyq~A zZO%Y6Goh|~cx=ItMS|RJPg@SG_HRIIxzK@85CCd{%7lKo<~Q+BC$bmB1B}VdZQFMJ zm^*0p4$qEuf=PLSsbmbkv4um_33^pkJa^Ca4_pNYbH~GixfGM50Hs0(f|V84mvy7L zK34cp&H^#}_tT9KJk9>_Kg&EREk9!V$*2Dw4|Hef*a?Q7^V|EmMAYSjhbhC;4#och zio)je5F2a(Wg7I|Xn;x&;ScbAC379tuw{3`BxOOhS~SjwAN1;5hW(K#u%+-gwH*cI zGMJ5+txnhHgopVmFBp43Ha=PUKA7vAxv_Pq07yH0TfiPD&3jM}&S(~LFIYu91igz8 zt*mK5JR#j^!u<@Oc@87GBHj83bRO#9D7e1;omr5aqE?90mobI3Bhc zz3ija3O*9!!>x+$xwz#4LCFz|rBt8p*t6>sOf3kqkRK~P(aaDh<;ZcOhXr6ovcLhe zAkmxP*ft)XAp7M4XYs6og!C~uk$8CvEiQ8O{s7G^hTy=T192NY0^i#N8Q}Y4Z%!`2 zS=xtDy)`W(zcmNcgF$x7wHbTh;d>>({Lu6nLrn&joVJVZI|&tA)ZM637>_vs>s5h? z&G0*rQ9lp1G6^n8qDl)=ux_p+$4+vWEtp5Rpd%XbuK^f35Tf0ju9au08U&x*OyHm8 zn&6V<{0;IzP1u*-AHZ>!y;|Og+F=iq6Q^O7euGun^4wGbMGIqfXg1&`z%DCi&P1oD_D&_H&arAkk53E7&)3pFEtp@5p7hFUPNDR{f5H{$%S z!Fm_;llN?iHNmIIZkzNKFPtC>+!FRn2XL04v>)~yzjd(3D9B3B3f!_d@5i$R*)2hS zo^(ng@3>kI9L&USC@;K-QGhI*Q)6kr{ijVQc9ccxk}E=?=Y2k;9rTu$z&HLC8SBb+ zwx{t&IA}bYfHEOb)IJUevz1ftlE48|08MdR12cucarOe+7kzvLaWqS)Gy)rzhquB3 z=*RrRM|SP{8>JRm7MfQ_M*Bmx&m2?%t|SDR@ii^t+g|7ge74J<` zs&t1(R_OFc(1HlNlBXDv|yp)ONWSylw&_bmqDakR{DP9v26wDC(*(8-(D!>({;d zf3W~nT!+w?2uRJvl;Wj$5PiW`o_GQHi=L2>(47q2Q3QOnkrpf!UFi|b7tsHPlml=F zpCx1aG7$MlVq=NhAwX^?b|3|)W-LW(PB%YAs60Ha@BTAD4+5b>OmzidHfF(c{Z+u% zOB2Yr&Zw>v3LBGdwylXiWnw@P^h&`o9`Dlw;{Ypsg`hzaO>Bcra=2oo)xAaEQks)r z1uN4*lXML(F`@AT3Vt?2@h+$xIYM(_qlAIx6bAO0bA<7D?ERMyc@Mssjf{+Rpy$5| zNqPY%qZCV98E!+{68aqo7=1x=)c75{J1Fvc5gslXb?@ZrK>oS|mSB1;Y%TI?l?8;f z`1#wOX(0Z;Ray|%$(=6bm{zK+LM?eFXJ57g3)%959y-pj8t)Z3|ATC4sir;3yf3x5y4PmO~Jm zoQFG7yuaoHWRhv$e~ds5#J2Ai@HW|v3eOC{fVP5dm<3J5MfBVma`hq5Cub;LR1=?; z|5nDp$AMgUtWn$hZAj^*byM_{9_W~bK?b#=J=)P&Fxvn*lG z>?v4e$+ea~p%vS3Ljq{jBBf2hw7I~Mnwr+%Mfh5blYTy8w(D!$4#BY4+58+I1-E%duqP82U$u9B_C3VAR3d6 z+6ehdIb5y%{YqbF``|PbTMWTvFs-;YGa?>0jf2^2+TUp-7*!AA*UWmwWh;oGX#8-) zUaos)XyG%!#5X~091FTawQ!uEw7ejLfJhVVkK>*V-+z5u7WDr6K6cuw~HV>t)1kd-eq+h{L zso)F+(1hS01{iI`Z2A@wR#f|K#S$sG)=O`(btSh3b5h2k{u<+Tl|HZwn>(f?5A587 z2Fv~~O|OhR8PcwF0HC~Uu#6pe{v+5J(G0!$7ub*-D&}G;ajEkO1?of%+goQ?M`3maTTWZ5`*0f`=)SAOskMJY-W> znN8q+tVr863uP?YuyyEj&w+281rgxI&d1r-t!L~RGJf|$Sy1#^-=$QhgoZZ%n*HRo zr=ee5&z)<45+Z4^jai?en;7$$iB^2=O)@Sq0U#A?ec+b1o%~JUZA`^YZbAKxz5}IS z0$}sE!Wu#RC^!qchr4llTr5$$xFSGLbBCvj4&6BR+5UvBRS~p{nx^Cbj`4k5(q$jc zDW;2+RcpGZ#zrl|poLR1H$cw;2DW#!?8BjSS_PM5AhVwTUvs2{N_lAzxgKhTtssf$ z0Pv08S?2M6$P#K@AvdItx~m^G^;P?9GvtGDSX?zW1IGF{2Hqe7rRSf9R;M7mb%Wia z_!WZD1c&x~N9(d8K*QdFzNXY*-nJe8y3l>V4|0STwgFwxQ za{ll4OJ$&%1ouO#JOeCEs^@(Ry9vl#St!lGT!jIAsr!lqPQ!b|ti|f!Xl4^#Y0-bb zof>q#w8lysg1Nh*MK=YfNwlXK$mNqwOQGDO*71e%*W# z*-Jw2?{Z^Byow-$;=mdGT0}T9kKqBp5L-bb*(Trhw#L#CJaJ1G1w-#}xyKj?U}~Q7 zDQ)?04I-E`gxYYs4Je6X>M?QJC?)m)UqOZY_<4`Og`9_YKrW@byTN9FX*`HXDxP&S zgi<7w3$^CX0>}h<^`VPakZl4P9}5M9)cX*^rfS^(4jv+Vj`hBL@GK2(Yp|{mO05hsES~i7fh{%v}t%zOeyhZWkk1BthvX${c zP1`IC&7g^{rh`zneTRS?fHYQ)3$F718UfdR!vuV?W`qm~Qk7v`OB-EyZ&}AK* zISmlmaFIt;{`Xeo>rhzSj5~RNz>)B#W*>o_F_w`T0Edmbt{KKbJhJ)D<3>NN8X~>P zLmC74F0-7LF67t!-@bkDI5^=j6c_yb^^5tzhbZ}wI9dpuGj;dNn}0N&b_c*L{WOR- z66vK+cX-0JP)TQs_fT;Myi`+20deD%8B9?q%?bYR9P``a{f;Xs8iSv)`ENCmStm+E z`{o_fhA1}=f^5>qn?t7uiUERZxje*qLZt|{D$QmrG~xkVQ1>O!!2j)MegU9c8o(Eq zy+UeC8It+>)}>LWLS!1xHBi8VtWIbBI-Bx*28iAfHT5BfE4tkGE9E3I`U$IsQR|RA83y3~gRYTB-h=h@# zmkF$y-#Ux2xxTWyNsFCOZeG(81dXzqAsM1WYDe&&g`xRWnhe2nW%2}p+*=z?G{y%* zhXyr5LW-iE1rNAZ3f#AXGCBZmAuvfvtSe#ifj`4>K~cd_s z@;{?crM}A0M+AjR@&BqebDQW8m&=c5Cvb(h^zJR>n4s&Fnz7xuIRM&M^BePg%or`!gG}C(=LoT z=Pcdob!3|>Tb2u;ls?h>G;BPvwWWl*!$439C|geo_n7-rl}vVnhP7!I-g;tjXu)>M#>`G@Dg+0aAg>z6 z88^y(!gQY3zLd}cS4?+~*}%nGSV?nA>Cfz`D*RtD0lEyGv4^2j?F-z?15jZ$oK-{U zn(X>37$UJ}dyNWnl~YAn4yq?KEBj9Q$uz!^dbjiIIo7R6pB%e$G|tBPJ@vMe+bFa# zYI%hMFWq>ED-uxU3s8by0|Ii>A?l@^Dmu8QE0_IGpRH0I$lFq+O+Uq+3Y3829hvEs zvrvX<-9SVSK!}AkvmfJ6G=M%S{WUA~#L%;0xY4H-sInQzzM#Ag12ziZ9tc8S;9ywy z6t0hQ6(0Uqp-Q$M1(S?j>sQ?BMoBdBLR$H&J2TM3gBg8;rd*)v?K{@|HtWTUI1%W( zwFeHNBW^dk9SDfax{KJqCR(p=HB?vs>~ac=X4hJSiG_MD4dz1to5Vwl__yMsN!*m; z4~264Z#yrx251oW0yhZ5y0`yrqDT#khSzRmaaaQ2n4AK)eLegmsI9uNj=b0h(UZvL zj;m**aqG#<-7Q9MRav`TzCTd_Ge{3)<;PXrxEG504t}%F9kZ}JG~hj_k>&rVgP5k( z*BW=fW5bzEW9JVIly;87-O#NN>fZdc)zWHBm~$ceYWV&W+d%>{y6d1-PRD+A>E0;Bd7)9pDqt=a)e8jf=%~!-k{hI}b`` z#uR`=7CNL|twvTInS+I2V)Z{VJOnM~*P;d4bG}CM1+3S7LgOgHd+-JCEOkA2EWQ@agmTUb^7Wx^INwDr$#rq`^ zp^e=qxJYp3Xe6HZgAAGX6^S>0YZ|(ZW?gGZ3`Q=P%DLHY9(Wb7KI-8L$9m(n#(Uj$ zYxx+t`aE=;RO8zP!s1A1Am}zbfSFFQ?R}6Yeg)$vj@qk7DF5In0w9$Pc+>fnPPo@@ zO%P0jB-lr3kAnsr`f0A$dv(4aTJE00>P6i05-L65b|egZ>ds?%jU2$&$<{FNt&;r^ zBwxD6LT{e0UPmy`Y^0;9J2HO>4vz6!Xpq~9i~OMS0@ty*&NQ6B{xI1R9!(bc^UfWu zNQy6r8HC&E_^vj%=CUa+cQ88SD+px>_P)S<%!uNSjzg0uElKniL64w@J~ zbWQv}j8xU{ZKxTPkfNRV%oP-LO9Vy5KUC<^xU5((ZD-w zSFoLGoVolK?|0nSZ>LJ_6_sXa?bqW}M;JYm6(UEd$u_lkQ;JK!!0Bxv|G<5C44h2? zTWo@c1h^sPhS5oUY_Y6-O>CSFxQp3a0Q-j_oycxs!etUrEnYKO0)aBfi+5QEkWi(? z-`WArJ+DB8c_zzYgVkA`f_75j72=dwa_%v#G$glSnFY=xE(1zh|m$CedGsKG@= zMen-_jALX$HRdRdkmqqqFHcSii0#rA*khozS0_^IypT}xi^pj@^*-#9@V1D1etvU- z){Pf!iRwb}A6_0;7wvd{iSFtv*Ga-%vuAWnO`8SSUubNmx$QPGJw`6-oPSynSkPyZ z%tsv?3?6J>bdS$pqEemfyLhECy+`awZZ5Dq<#2Hk%hTne?=E5ADFp|5?tRa^ z&x!s?L?||{bMzkHavC2CaXEvyz4-f~VnHN16I|~uTsUxKoC&9U=zwh^vf>g$2EzNZ zvU2#G#!GIm=FTlIFZV0+Eor(mh4gUrOg?q?lJuN>OknH>U$cQYqfF%R{I=WkTg1Zr za(Pb=C3o!VMltm50s(!AkT0Hm(jPYNK5$e7W$Y?C`OA(vXLsM2^3!;O@$xIu)a~!} z%w(rDnuvGnnq)jyqn9b$f1gdqozCv@Q78F`j5MBufu4I3latpSsN)5B`b4sGk=S9N zBl`zPl!1+pNF`h|6%F&fM@vq72*Y2mv2@-Fxtt%XJOkw%5uEchNE1rcBTi!_3NUm zAvfeKp7563VT1bvFH8lN?3kOMr$yf#0dP8NSnR%7vVIoyL&iNc3QG3o|i9kJNY1d#m~nl}sPnSWa#&p37ob4lEimzdff$4S#oP$@kGJ;P2BG3j~9m zi_SCcN(qdp;|(zmdh}-=cfGy$Bdy@q0}YHxHfRh z(us=(D>2UF3=tkn!t%?O_)m5{WT)hy%;wLZ)fXyHDBehY zw`I$g@&x16A$Za8)t+%y$lIJv4p>`T_n)QwIJ+SmuMflWO-NLSTWL{KKSUv&mtD&5 zu8Z4OVLj0iu=iyZ(s-7Xlr+&^?>T33=muy8crS^5DZwoi8J58ZQ2P>?7Ncj@d912< z=AX?0Jp(HJDi(kJ$~D1l!pLgtJVHU9S0`WD4sM=3I{1?YZ>#W)>x?Ktp_?!tb$OK9zBiOHL*XhU517{dD1_rv6>dnaz$rd-5D1s zLD-nR7wGTS>tYOze*D)<;J99`U0v6>EEpB29M$^6l5=iw0a89>;-tff5^1>E38g>G ziYnc-WXSDK08icB-udq0%bC}-`kjzMmID+UZf6+=J-UML4yZkzpK0_S9UV>0%e$32 zZWmMW^eJB!Lq($lAwo^7Gs-_C*zv3hTn8iGGE+vFJ5Yy2ob|mtCKJ2L z?`HN~k*-&ae9|Nz2&MKz(HB&Vy(evAn)I3AuiBzI;TvtD{k3p6{Sjf$_$_ zACTbOVVmi>^EEPAQ8&)Lc<~}+pUC9eZ1X-9+0{o&((K4bPcoV)FTWf99YyPUzfxtC zQl%kR6^zn{7wW(M^FuMwd&K6v34+3iyQrtM_;={tIe-KqM}$YdUOvrU&}H0D6b2*i zD(XIMEsOv8v_bV@PAeWlsnyp-^~XlNx7@n zuXNshkv!b`1Q8JVD#y9%50`{_L_?K#5tOFmFLgejEhkzniz>muw_UfjHBzwI&~i|p z{f$ZjEeDmA9ew3;V5rW2s9TSF7MpD2X_0Y!?3_TOs)nRo(UAD^Z}ldhZ<)pcTpn?A{6rL2_sbFFBSoP#dU`T2XK_bq0!XVI5XQOSIYwZW9p zik|=VBlvmWf#=s3Qw`7mZjB4)eh!m*rG&;brE}B(ufN8VsC!lU1QT7NozJGt$mc|j z;a)qpyEV17hInM!I|tU0r5*jGOdwx>t35!!a_1Dy{{8#i+41ko?U$ElNTH@&<^VebF3_Eofo3;>^WQU3`OgE+u zEbl%OnuH|fWS(W;S0X~+$t>cyXn>HDl!#HzAEFo`;> zhe2gW@qh2sBTB9b3AYTbc)*BM1@{kbJ5028cYq6qC*fmXv`*Gey)(<<`xdyYXR!6O zEFF6PI8XT9P`9M9--GMdujk?Y{l(zE+^LpF0~e$RSm?neSe+Spob`ExdK+|KCum_j zr&z!4)v@OFrUo&h`0caugszvb4D)XMY&a8>!2@4lAe^Mr_)6%k;_!E5Fz_`_dytu&{*J10FD@)r37l+vYwG#pO?|-m ztOlHkaS(!!WggdhyEi&Zy;rkI9kuM=4^60Jr2W{Xtcrg z#->$c?Zt26t0@f1chX-y6ktNHwC4?8MrPXB?;RN(T|EOI3`%|95f$tCFl@D6d)8eY zjx^Ww&Lx@`zIko3N=ltYnG~Gy?YzX!IS)Xf{1$UQbnNQ+Vkc8Q`CUs zH;J!=yhix>y@nh&vu;A`V+NOz*KN<1EE$H7YWN!R$;!NCk`XKWu{mb4Uz4ODH}@T6 zau|%0)?D;K?u$l76s{^;dHAGH{TR$H-;V6+p_hgEyCn~sK+B3+>mfyS*~ER~bd~}f z=qmV}l<m&5iM`4*fcHz-okXfeQHp^lEJp4jXT5hB1E|`(afq5T_AC?ad4JpH{n`F8|2tmy( z;frsU5;&jm*o#h(9|ieDRwGw>-zo!@iQ_q)t6K17+|NMPKnfU<@ctH1dpBJdd4(#P z@*^wN0BhaP613chb@PQh#qh6IQYfCT(NeN>|zs>*y}SRrT}F zyH(+1Rz06C*FO6!Hj(j&8p(-NIFBJMDwI>i?=ZksCB_1fe>9zfgyAKm73!Mcl? z-v9a5NAOwK``FwXhv`!q!8VM_-;rMrTRSncWg$y5O>vOq-m93f<%$oi!`8WvAvqe+ zHV13ZQLQ^WJE5uoA=ZP5(ytGXY-!%5Lhq@awHeQyuB0WO`jUCogt>{(+MwZS)NlMM8C#a7Zrk}`Jc(+9txt^eZw^ksac?Ox zK6hPvUsV-1oQmcj=IF$7rNlin0e3CnYCS4A#6!ti_Qyp&qWC*PcHAF7V^5jf?=i+iy;v zj}nf9oSCLQ4|MSv4=}5tRD9)q6A>m9y?--@4;h(EecqhOHr1=7!gML0x$k!^3;e3Z zs=d%Zk}vA#=EdSKf99>`WO^`KFHIXqtbuE@;AGe}B7rTW6u%lr>B zroA?6KazIEl4|;RHT~7@ahD!$RyLHW+`KQzrgPl-B6i#yqIq*Awjxp&zDr$(+1#UE z__^+}M+Q$4jhcUIv1iKEgtnW}2m-fZWzy;iP} zQcs`4R!yY&vjL6v%r9mEMZ@hoI)`s6lyJk?Ht@7Cx(Osli-=N`MP4sT#@{|x#tr)} z^Hhq-VW(5((T+GXnwguMvxHTbn*KE~z<0*l%kRjtaW+jvY}XJ8`y5+)f9c5m2xpy-}fGM{C`wCbrm#)j zC8vyyjd}9Jcx1)mm>N@1x9+wWGgejgql_6>*2YAWuu!VlHl2?M4f z{N!A#@=6Vu$gDI zhH{obP&)KO=t|n4LD*wB6K4M^eSVY zA$KuWke8pw;*xOh^7T9iQhzk4KemfOVwbKi>+>>0Ie(J5c zV?7+A4?@VFb2@;0V)V?9Me{&{S0SFj`n`Mic7?1wchP|#i0*(bK?Kj|Nvf3STee$w zVZalun!Xvou>MV{7b6l*7%M_k%u8h+=la91nwox314+tivr&q(C4O2QkTb6RLb8Km8sRWv1)R1-*D#EgF8{G>r zuSR;wHV6*`J&u?#T7cZi4P|N@UD@KFKfA4sVg;y3AU+R{8;I4^&Mz9*+3vJcO_h<< zEyw5SZO4=6vSG~NoA})Sv*u>hj@Zx3{FyL>w7Prr~zCX^^L#Axd1s z7deE#hEO^gG9?pu&fqG%jvm}`PLll^&c9c5BB5qPZ04c{`XZq-HW4nT%R%?SNYTDC zyd*7cauKR63l-Xp;CPdD+dDdzzQL|&Y^UZ|uU0ct*&fjnGN{_+LTI%0F}IlO`QH0#{`rp=7ma|lWt7G0v;Z)@hrWx1#*rZh-1FG>(Pv*7W!u`-m zO2uqRLxM1QFuQBisW)uI8SL7Zbp?Q2U7al19Pd%gm&ow6L*Pd%_pOT0x&%gg1K3le z>~50eNQcL~ddphm+1XjKn6u%<0Z~3AA$UvntdbJyMxeoGm;2|+i-#JiZ2#2tav~>sW4G7K^%V80Gyt)7K|091 zUeBCUsbxBr*;;P(Xq7Eqa<%^Lg55zv=}nP~ z^j~_P2KU#yX=wEG$vpLb^5jWabRMNfyCOfLn}cs;Ke@TR-$;4u9R?`h%oG06H#HM6s`i(LTXp(=J!97!sz z)QUt#Mga-7$}59Z4j8xZWj(_ATbD*w(YlH`e`75^qVrhS{H$w{8*EX{>dTYVx~RNO zkL-Ty4QP=gP58aOwUeSDw9fyKOjCJn?au~Tm@i~&$o^` zgro zWI7jz%`_`+V7wnZ=b)a24p_5x!nFg(CVf2HvnbN7tAR;~@r^qt`5WmGBZ@xTEwt)5 z&Y+n+LbN-~N>Ka%~HR=G*i+qJd9vDg3WXawu)=SmNv-K?NkBMxcJ(gP(q}RnK z3qv@+n=W=KH_@qvnM|p;-UY?q+VBS1Q64AY9b|a6P^pfMg`2-IF9nqoEmV{k+fPv+ z-?^RskAXUpD*EI|B(n{5bM?ipUfN9KF3d!ryyeGHYUi$xRCTQts@}=zLHp&|Redq^ zS%O|fbcW&Mj*Xn3u0rZK^GLrjJV(;)btI%#8~hwsI{MYgRH>`stgvXeqZUeDaZrh2 z`${&YKc>bTPja!-n2A%z^*9)%KEirm=a{DzadmHS;4qZ%%eLDkQ2HL)H@Ru2v=%@H zQR*}7RB(3pN0RINlAFJXcI}?}q*R{muwx9C!cRy^y@PK^Im4(qlbdLoHcv0&*5&%` zEwp(bm=_W#U4$2xy#@!Qm$zq>B>kd(iHn|acQjs%aI8{-BECwzM~5nu+41iu2c04u zD@%ikg8}ftfAcIb??Eb6HPr7XC#kY0rwwBF4;yFF@H>4XdiCW~^@Y#@-uOhC1J?$+ zKcsNQC!tEWx45SlGV_W#JN)@6-a4%eZcjMQ-w}<}40^S%+ z8gnpuJuuY>ZhP0O5vU+ff4F1b?yhNfsAo&@wPh=6;5aCQ=65Y;z{EtiEg- zS^5IlmcZxVsCGiNlJ~2)sQMgKS#@MzD91_jgXi(BrwW1FFP%L`?mYx1#M-T>rwIQl z+XJTujje$2D&8_~{!IDDa9l*}ew_ktbmuuE&52{f!A?Q3EAnLsn-h4g*f zFBuzCyZv{cH3|(0aWjV*ec1_4i%U#QY+U_Sz4teo;xm)sIiq}(N&O6p<0H~jI8>9Z zKTZ>d*DV`N_b$ zoO>bgQnkaID#fs1&e?xXwNj#OxTnwV#Yd9Svy_GKO*G_ot=Vl9{KE;_>3lX^ioKsc zeKLiMhfbGh{j9Cp53bw9{q^Wg1(GGCSF{4jes*q?_3JmGn%CK0NFXNMaqRj4wt<6Y z%WcrG!l-$8Rs#GjcNzqIlJH-V?Bb543V{O6l>C_z){iA7CQzYj(In+OE$Ifv=%qJv zS^|1HSD&1cLizzHUMED1&HW+rtaOMRJvs*97wP^fO3M9jcS?pt$%Sl6lV`uf`&w%W zC^a~=G<3GLExh05y*h(`yL)(c4638P_?blx)^C9c4|e~xVkHt@lv2Q#y)YiLusaZ$ zWM}Q66JeqMth*@@|EAUD%l%J(1kRN3b@4;JeE$ppAS4HP*YRDWAN`CI^}@ROR3?qH znftO!E}954p&&CxHV31c#S;S~LRBLJwmpT5Ct$uj3>%36Ge<-M@>!UfIg|V&<0tV{ z{vm;qAU>xSUf@2 z9RL>bsekhT1I+DKi-?Gj%z_`=Z6Pe_TUEu098|>N2d(9sL3SD{`6c&ph|;#6qx7l) zN#TG{cNaP?cez>0C~+W@RO&?-EJ`hM9h3x0a!vGO7PO+$Z?Yea5@n4gN(~A~m6I|m z?qicU!#S_~f=@$myZ#E-5;8hg6~z(vW}HCoR6I}Au-P>=yxjAWfKrzw*E*&AiV6L! z8PmlRlnR7!tfXPAxO%t>l+T@64ZfvEd3iZ| z<7;=ms%H|u`irF0(`EWZAwOwTT<@Imqq2G%VJh7AaBTpOO(T8Kd;r23fnba7 zVhP6v^h=H(VIPSHG6{cl7Sr|Znpf%)2T($>%RB`#+11qmk*7oeq z1omiNi#y7?qS!@7eTL#H_mAFqWvvV$(7FB*$f=5rQ>zjf!Whsj?M;iF_N$@N+ zH8tkmcV8WS-jpm75=U(&ZrX|D$DOmG7loGO51z*wK#dgqBi-oFXj%@E|EQ6;3)vD7 z)5aq-6Hm%QD+UIZ&@-c^|BA|Pr%J*k9ON;yAHxwYcEHAR8ax z_k)tua9I}l(DUdjOHbD9W8XT|VM76l=l5`zjQGq(S)mhH+8~gafP0OS-%s(`#9xGx za2?Vl-q?fmob-&(*|oYDC#f3snm^$qgWNGJ!{)PdLXKGHXi?LdpSwn&oJb&bRoVSq zF3k=GI)9IImUPT2SR_6EW~2R^`P3$g``Gq`z;;?kP}>fK?ACM#`E{n>AivX}eo_wM zND@@_y&{`H@JJ*#H~tXB2Psndw}5sCl>jNJysM>?dO{r;dcV5_yIJxy7NKi@P)4l^ zLZG*UL|Kq2F9*lSuSCxEAt+s?jX@H5;G|#xGGBaYKn|t!RgxsB4@9X=Os~Yt>_$HI zpWx_Z%Wk|HvNUUZ7!1sB>yQgaQ?cYHq`k6RrcIw|k^7N+iC?V*LL4h2P?^H+1^Wof zeSZ?pBPm{~0^J&U5b^V8FGo`x0+Wf06P3ZqgiW@a=XA*;CXbI|9X~LX(F-nU^&C_C z0@Bp)^oH>l(9&cI)<#{r>RcZQHjjJU{1f}hlerOy_;8}9r>3&>gp^x1u%o^r+ct=K zC{U}#_P4A<$5+5WV~p+=1{1oZ0E;va+&hnfA$% zVLm=T*FEAUHjSK9l%#91J3~Yc?^ZE9oFpL?#(K0-Mu}&!LBJX%UJ6JbeeI9M+VD(O zl*9r0Y>4U%{Ut0~{!1hh1;UDr^TDOcu)h--3Bl{4Bg-6{AZDI!?GUwJhWPcfqmhah z@77EOwKPm!cN1c8G$(HS{YNXh~rZ57ZFRJ2y%+)cK5$Ao*zj(2r-{s@{2}piK-gCMx-0MB~2G}hFI>o8CiOs zwI02O9cplUIt$~Xj8uuexS`OVZ1&UOU{sSdgq5>&Vl6@s{rH=LSU=Y+XfzmrPm;E% z#D@Bw%iW`htg)qw4f1Ifrd^so?@3N`syIO$P5U@fZx*7Po1&_)d4|$RPajx4d3pQ& zwTL^dGNqko2`MnNCdkTKo|&DMyacO$uw75mcO9hX`Kwo%LoX$you5KucdZIO-IAjQ zDtQb5pBbz=V|$ob{KQpgtRjQ7JocA2=;!#S;W&13VbjcFMaj8F`iE#}BD5?>puD#thj~!T#9{a{wU4XKHS!W=4)=D!I#F0i)6f-$60C0N}s!U&J zUpdksIb_5;A2#y4prP@(>cAe|w#AmL-2e0i88*>TvC}betp!0ci1wq?W7)@#SZE^? zfoZsjrZuSTzq3m7dOAO17*SHm^-86_8y*@OLf3iCe!52<_C}Bi;DS|Byd~ej6A!#r zJ6QR5Ma6?=>MPv4&@|i|fPQ2)n0Q<*i>Upi?W5|m>Qz9*Qsp?Jwhz?yy25IcEUHLX zNKq75v27)O9U+n zy)0_#EC0~D{bH2;hWl`W)et((Jl!7JI`Y>VIlp`P!};8)ADuf6He?iqRt*L{eK`J5 z05vIib&E(=#3rQv`Ge}&=jJ(`?;!4RjkfC>*M2$5;|z;?R6&sSYimlQm`r;yX#RO0 zCa?O9UPxu5+sQu<*vmV*O!Vz{`EOkJBc_HTee6$f!bN_WcL;<_6_a75U6>6NI{}MX zIDf9yd&N?=UT35Rfea0%;SlOFYQnX&Cs`H}OW*FgqHp!%y zU1{V}f{)PqYKC+a2H_+kkykSl;Mg?($qnlcCQD?dAkiIfIK(&TG`Sdo#diO_QS<(@ zx75E27j3?T^)gw^bP8N;P9H11d$Y>bs5kyV?{-re0S@+hppA1C@6llda zF~jyn#}k9!#PwGZ$aC3Mg{&(OqR&ftmSnlFe~*QWiB-oej-7&-G4A}bJ_6U0r6-PO z{LpIg2322(XJ8mHSXaJZVzZETns4kJ+3$O%C&%Y+HQ6sSlqBVD#!7YB^wiVpRi81R zR)iinPWyLW!bb!=$uTXS^ZUxjz6)~tFJ$l#NCiV#Zh?XEm{1k1I5WWdan@}}LL;vq zUNJQ9I7;_w9&=cO4j*e$5dd0u=0x`{-pC#cxPQ=^#`eGA*^BqZ8=6I5K@d6TBZm$; zMd;ONbSGK%{sYB<>Q1|LBXA67@NYjlD=d^5^L<2)3;YIV1$BCV_-3*CF{?;p#XIX{ zs#?nS6ySXyT5sZ{n5r0ine;kghnKNP^@B%R*Us)jmEA@+ty$+oxiync9dgr*Cbni` zhC1#Q-=bGAb)$Jt>>{UE!dv4z^(|cXdHu-N6%NVdJ7n(J5@DOY$wOycLrt4-p|Cz_ z%bsupSjzVFb9oI^{NR9C zmjZL`*o^PU@F|jqxx$&b&=PYIASCDRewAQ##MeDAcC6@yI-z*bDTwrJf}`fjw_S&C zD_9lee=nmqFw+aIW(`9$vzE^?MJ%RnD}{riyBl`glN63QJeHrBb*;Nb_{wx|y`!WkclZ=MTRejZsEbwPd<`Z!vx=0 z(68ev_a*WAw(otGI|KE;x@{Uq*Xv!Zdhytz)+)d)xoj~zq*Xe2$J`C|QIq{|EvLhJ zaIy{uXdTS-F~m9~fy#tqHH51pfX_qLYh;bh1s+ z{)3G#zl&tA7NOs?#6+F# z8AmJdlh?nyV6LQN;WEx_qS|qF#0;cfF|mwrWwcxt)K<`6j9;t1(4d}a+9B}&(8o1p zX0zLm5fFOd)*l=B=X^(Yo}9x7+{kpye+FlY6wuxb)2N|R8i^kzTOYJ<(x&FFmK85M zeQ~p>%O)7LCGDKz=W}UunBXgAk7xhT0JmGH}3BIjA(Iusql|HIc(5?cOiH1*)ch2u9)70TFum zhO^{SP{E5hYEW^0k(-mq=@_^cC@?|Nq+XkZ_7j`6>j|H~ji=KwhXo0M_i0eNP*1+1K$`5MIn z;|o5LHLjjB6cpEAG8{OtaeW$j+xyi52j77Me0=A~nR|*4&z@_FT0iKc*xGsEm|D%v zgUhkJ$?s?LTSROd2F{Lu!spQ4y!z&bs4@kb24Dt`0XKmUz%`O6(lh^m45S3ppo8PU zr-u|?$H^#PX`ni8+Tm49F(b(+P9@&mFVKIb->yI7{u`R`FS=$*q=`CH8xN@xjT!G; z_co%iT{C!Aws?kRMMG8C!I(usfbq_=szr+Mi3^ZrWF!4lZkHJuVk$TBN&rs9*U3LOjcz%AL%e!kr`lK#(9Zrd;~1(j%O0=7;tiQwXw?)<^~g6~1IOMKs4! zujPBxdeowI#6w9#0oLr>vLold_>Fi7Or|92&*};UsdHCF%huA2&ek1x3=QwnpaJ3I z@Brd<+a5}9^c))L@nF=I%$1%~or{s7kyA+F3qx@EK_d;_n~d_oI>nik9e^5%8mU@_ z@`9hGm8tFp=TsPJ@)>0R+Vv_do2rPk zh`b1pC6m>G+$%9YJ#^2dcJNu6fRLwmOm$naU~zr%lVbYf-1Z^LxKWB&R@mWf$YG&6 z(V-hW&nx0Qhtl>Cn4yDJ6rUUy>9JKTUrbfJnJ`o`)*^IRp-VQ+F;<}VD|f(=dh7s} zx`&~MvL|me-o2W7F3fgeyd0v!ULB%Bh)BitNQfbayF`W_3wWWR159EgjKw=?^~*eP z@Nht9t5!TXC{lSTD5Lz&!X=FmGZ*m<LmlGeSRAP{4euG{0>3Gm+lCr*MRQA3`q%PodwI?6Rs{UNYs)`Bx61C{yP#7b%fsLL@@R1F0gfAVLq_vo(Uj z%y+zZ>fZe^6r1CY9NBr+xkpGV$?XLHId8}(ejbq!=)sB}3$1*xC4yB5dd}xpUY({r z_^$bm*gW6-4yzl>)c0g8|E)um6E3pNTfC8^@%S7U=Sz1!1(g5+7T(%LX`v^Ol;#l( z((RVRY9n_txW&Z3$^4(y59xns0JUn|YrYRhH^02tS90BA-v@efhgM2YFwJ32An4|G zwn|W*DTDNch;UO`Q>R@?$|eWjvEx2IANCd5^VdcXa?nb)`R?F9<{Npg^9r8s2s<0w z4ufknp7PEPw6)Jlv}_w?T4c7Qy(w~M@!|^BZdTco{)B1+i6<9rT|>nB`at1%CaWpO zzQMB@PN9}>KHw{137nroJUv?&FFWF$;2a4j}SPG*-~vXl9$GGC1cl<_QSMA7?!F`GEkS~9^db=4Gy7eqK`1o=B%vWVv6kQxl}04-mD_uMH?ck6`}Z8}Nc z+%p0i-a;htQ9XwOq;h0%q|7mrM)*-RcpmSqdh=0`F~h`AqonT^i$VyMW=3|R{@hoh zc}VjZxP9bn5#rWq#Yy|H%e{d^Z=?BmD?cBSlZey!^vK0y3`yVtMNpmpI$sRW2$++( zwVOn3qiFNCwA!8#Osb1Mu)T&i`<`Pbl~ zWT9-k8>xz#*LqopJfb;G1tMBaR*hMv21u=_$2@2H9~hktwH#JzgUL*V$UNqE2-b|2 zYRZ~9(cC5Y0!2K)B|So1?jze=tX)S1nVdw1x^#T=G(n)fLvP+;Z%$4ENMio4eU%;q zTiztIYfE;G%Xv6o?;j}SYZ_E|ayQ#Jl{+hP*@Y`d=UeC4?jP_o7*zwBY%vtv&zSfG zRl3cN)P3^T@5}nF%MrOUk;$s9V!x?_vmrDH()mM)%kXu9;9Nrkg^lwsntpC@o>*B ze>Q&~pA?SE6cwc2#jLZON;;SGEqxn6XpU z+K%G&Mx#4RKaD7l4(~L`^lw=lo-o!&JG2vkEMMBhuX0cMCGy6HpyY!1t_x@kkLDA;eG znvGpE7N%!=*yA{qBpjO>zM~8^s@A`?CEZg^buOH$uKHlvD-osI8^zRC5|K$;B<9Sl z>gn4KDj^4AxkSHPI*{K?;_%LK6=GaazTi+9xIf!tZFOnhMV85+_GQ9V9}$D_kPZ)- zc~@DjO|s5o$5ZZZV-*NtWofewVLk+*DRKBf)4OkJ`hxAa#!r!G2ZyEkTe7QN77l_BG@7dPL_RKIVz9=lN5l^q(TVFZ` z(UPPO&HjGQzr}`~fE>XfM}BK^)CD}x;#W)VPotO}sT6g3@y*MxtTlDXl|aQQ{&jPH zM*W1G;@3Jv0n5SyuHM%59Hi@n?cp<_-(cY<#0(*p1g(t37!ku{``i83`9;~my>TO? z^Az(JWV>sAkCb#@ur9JVEZ-?7l5v?CaHBY*N7AGQEHk4k@ifKaf?{ZwAHv_(E3Au} z!ANgpt4l|+pjZHEUs;zh>fOa5k)aGcPY!*-v&O9D->Oa&{Q_!weOHd_Nk-I&?f&+M z>yxM0BcYTi`WfbJWV^roUP_`Bs8Ol@FV)icmy8e9X-5-UOu8VZJJ9*XE$ilrzXx^) zC-@3`Nn1Ks)b$Nx8Oj&cDP_p4=*ve3^3I%jC@S)`tjeV1g0S%Q=8Mg8k!NoQ*GqM= z&MSRMk$n&y?H_%F((`f3v*ts=FD3R-a%p}s7!dF8`51r0c-jSniH?_A8@i*T*O7E% z8Hl0+WPr=gfPYJx?wo@!{vcR|SPcC6!tnxJc~_qI%zk6OWD%>TwnU0%1qy(EisBO( zfGuw1S!|`+d$Du~cq(-f#j}aSRVq9o%&om;Dop$mX|2!~Dtoo`J!O{z1Sry;qK0|a zi^u*b5FwNk&w9E!jf+|<$mw93g6U%lI>YI7%^v6g1i(%`7i z3r>wm==)Ayf(DJjVfheJjqgE%EF++etmG?i#zmfoJ*u#$)+g4Q?FoE{5IaPpg#}bF zj45|RJk_h8y^|H-|B(N9+cN_4{OK;yGtHg--;>q~1;dpO7dk+S7^XfKZCE5u8RH67 zpZpizAl(LAEv?@R?rjySVJ)an=9Raf^w~>y;OL%1(ajRJSeA7oy1Pg_k8$hQ3s9s6 z9aMBvSH0%2dmBL%grH765y3rG=LpZXabvtnu#I?WhZ18PbMk!p;iXVONhbnoOuAo; zgCc5wKe!sy-8Tx(#!x|zIh{LT;3CUgCOVW+uy4Xvp>Z@fNf26{NZRYRd4w)q*o&nS zd0a8pINRH{9khqQHlwIvuxSJ_#;Smv8y$#)a-HjpXr#8T6&Dn`ym9V}h?Aumt$uZcDNv*L^zvj*~;vrTaFrI^J>C_UTJND(l6eXI^)OpsV=e1&Sje;Wd) z>cFDZRdUBS73fox(`{@`uZ!w$p|n>Vwr-)YO*4^}pxtI%H=>s?v8tSn%G+vzhvfTa zF!vL)$qFi%J+yar4*8z3eamEXbSR_e#j@ZU-nJ3(H&5WRz9p<=!=b{wFz+Slvp({5 zFD?eiLZ5lI{3h1rw>gw2B!_}^&Icx5FpdozXnY2m5$??S>r8)$7RO6zO-$j%$ak8n znC$PA(D6mDvMc1ph98@7$ahP8e>q0ZbcUQ((42fHUTpee0;f|7YlwVW&*p5dvBRnx zrhB?@URFuewy3mj+L@5%cxXz!GIM&d6w46!GS?TjkXx<27(kN%Waw#<;!{GbL9?M-UF`a1IUe@u&!NkEAr&c!(X+?5BXEQxs3S&j z`Ddr8LgnK_dr*sxxt)kR-@S3{1+FYDUing*ruVx^AhZdJ+_Fvu(%pzdv7#)2ceq+! zj~ek3Sj{ZZNduJa_1h4XLJ;Ju6CoJVP`&OTq6lAtJo?P*XUE@KM2TX<-yn+wInHSs zE?jC)O}wWo8@g=xH93hS>k)^#81Xar=xUEm&KewrFCacK){yJ17dQ-E?0a|NN1X)X zD{D~IcK3_c8p=jLUEsrM83!l9lMTBpjE2*jh38 zEHS?bawz`=86!w+$TQ>8wdNVON(BybG7bQ=~KAwUzhR!_r6BJV^g&)Go#*V z`-k(?E0NVZjb4W`9@;uS(QA_xLfOes@PWJRUkgBt7tCKtj&+IauGbqus{xJ_#HXk= zW;E;H;c{%f!zhq2_(Vo>8x=Mng8_K&l-qfgxz=3}O2+ZJjL*ML2B}L>oRx9Q(4OYm zmBurL6O{0TYT`xgT+ROrUx9eN`i{76dS$ZtQ^i`5lqmt&AgKsQ&INd$7+y$%GsS+B zXSaoWLLlKozTbA75fFi5+oing$GHY#5hXQ(%GRAD$n{U$Uow{1N4AJ?S!*0-U~Z}V#IU}4!+`)r&` zf?NCLl?bP*$ho@H)YG9ykDr&_UplY@kOvr?$@}->sK9<6`~%cHBcTKV{{_X0Ybsm<*=mL0<7X2xbQXz0@g_|3lWOF0Q^yr5doLZ(WX`c4U-)t;o3t1A!Y0N!- zcWQHHi{okjQg)mjBYR?##$oteP9Inz^U-a)4mwJ&lq)wQn{O zvRSi<;}luN#HgSULAQR;xnOg`as7y%Fx!%F_O2nOn5j6{!}Z3GxJO;IP(W7A)y$9Y zqEv0ntvv>d$LcLor?N4$Wm@ue!L|}kr`yDXrZZ^GW*S* zfv%FwmT!-H2>t{ZgTrw1Fbs|Oxt55p31c~Jlhdc-LJzwMO}y)_uRe{VGJX+edQ0(a z(r~9rSO{~X%Qt%ywyn3v^=a|nIYUqq#@54P#U3pzn*AXUGMLOiF$-S9b!>^qVrYt! zN%30=z-XA^to8k^<%n1zq!@2^KhNvm$^%q~@|a^B8cBfi7=!abLV2(r+S)hKmV^Mb zM&4_-sa)fi$ZCCWL@}lN%`SrN9^&|(PSPI{2Ks?c1c8Z_+XeV%NFooMB;wee=(s

    l+$dHrR@u`sy)hxEfVJEr={zRZ_A$g$vpV(NX+eJVwQu$+Y!7aa z7qk3;1?7s@3(SVt*D30!1&V-vTJ(}D$$WJ?&u@x+>@$d>FNRBHp0uA+2*`8K3tr|! zD(P}uAAcXhsYPRYxH6IML37)NE{pd1ZI6>`{m2kppGP#x^T@oKwM3q5I8g`X zRtn|Dtbl;NIT5z2^CzJQdcqR^#uu|v$MwV5?+FaKlgyv^k>U4}2A|_1L75cPm0~_N zRH_3Nzs=3-oE4xkZ{gGF3lzIqodT_Ux9Gl7r7^{xA16zhpv)VPdn->$_X->AB{8O8jEOCxn4`ARfE*1%$3i+*}7O9Li3Xf1P;RpS`?b` zUCH{L;_*wgNB^W~b=dfH=eIb+< zG<9sD6rG;phUj2gYvaiQ7rJ!%5{4le+nbA}s;g@YySYvB-e9 zdgKhrZUCO!%4s*?@}s%^Bo*-hyjv=BT04{8NaJ>UX>#vv$0HM=t6OhThfXiNlZ^lO zoGO=ty(=>d8S72oWfUqxGJk%PY4JBX4J=0a?C-4YWr@5abbF4W^o9aY$ox>sW1%*M zZIr`9?89ADw?%@1M8e%mB<0v2RZ(r*=fA@qbu;9)KqJ#w>D|-0oSYioE$uh?PqlZj%&&x3+SANAbq}m@Pj* zPbcy;D(kx#Y^cy)QB;k%A@!dhDPz8JYtSvnT84T2RLC z2FnnCdI2xur*e<>)t{oE1y}$9V1pGv_={09x_ugY9P(N~Ipe0GhW@Oe;?)pd&5zjv z!fP4tj@0fJ6xIBrABS?#Mk@u-Cn+Y4x<_IqsmFb&H?UEG(JkP2oK%7>x!}Ug$iN7X z1^|1rLQ|k)=PJX>yxp-27q&x4g+d4+hR~+P^3tVCC3xg<2)p25B#xKx7)5+Z-U%u` zDUn!BeTOQavPy1QhfdEUE5|+w^|7FCh6;#kM3}@^q!ssQZRdxC^1DZLZkpkRp+SsQ3}k9A$A> zl}O=wQze&jM(Z7?=y&Y3q_#*6(=_R3l|mzr+&DpEzRd@|qXXpzURpr-h`Eq&GoHo* zU<1Uw7~SfA1LHsza9|YX8%=R$fwbSQs32t7)b*3=xW$6JeKipeQvuq#LL(v`D>MF65y=$F*)8QL$EJQ3T3jwW0d52d|fkUH-jjvpGSq>p->(;r5uj!$-+>@c>k0 zz24aiXRq%G94nBN5L#Ylc-{K`-fo506@KGfrv_-IxvZEAJy*HA6jN|<$95dEykLi@ zCspH+>v|>HD80w=?n&#UnbFc<4gYE+=Y4aajIy}76Ugbk{We6%Q8dFUt+gpS;4rm4 ztA@CSsmXP0(8IiR!EgcSUosBh<#B=YJOhZ&sE6PVP+kTvCW<9gB{T<|+3j-DdB)u= z9z^vUPkHlkf!Am54BUx-E61g{Yw~y+(Q6Up)mRJmI$$FAM@Ksef#-3^ZeFaAGX6_n z88up*goziJd;{Q)xV#NH{(DhX9>rrq6%5GsodyiZqGlUfIO~q>?ak0L)^5rQk|3nOK=w zNy6LTaT73rnGh@5%tCFIvKnqjRBS~CsP9RpNPfhk6vD3AntJH%>;YO{MS8(Wt)OcT zSw!~%sxZFWvA}?XIgh|B!7Nr{IH@GpZj+tqS9Q^`|IKh_2YCF`6znj^SMMxqx>?6# zfW(ZoHtsxjO(NBm5Y>JHFx4NB3g>#i5(5<1{22z3_h2T1*3{&ik;g7GX4a(plz=j7 zAb&TYm&w}3Qt*48_7JX~ybLSwj5*-yO4eVy8eQY5c77WTbqI}TL^M_e6+TFH!<^B| z^KVP zw!ZDkSyA~g^#mJ-SM$!>c6%;93foV|SlYxyw-U8xSw)4_W~O{lxNsjpVn2t8r7T_i z`t`Ph`5a%eUTK0wXP;`8Zb>`8ks0lVR!yyJ!8CtZedojaP+!xp+G7oB%<}Yb72Uov z74$i`Aqy^u&wVi9#M(M0xtYjd>lOv&`zEnr@8LXDXx*B;RBTpGD~1~1*%u~z=f9Pv zu{Stz^!8}Du!vxI7)ygv=~t1@b|UW|iqwl+tZ*^L6?Wc?z+8(E9m-vtew9YxJbn84 z3jN4LMc-7!QbZdRP4$;nhmE$wUbf$gq7ZHZ#B#l5x2nN4x^V^~#xJpPPCyyo`lCu~ zlz|?6f8X8zDn&460A)vhT$gF|u1j4#4eRR@2g4(G-fg5lcrbUc+3ZG~k%RUtq~2HT z{rk@|?Tu#Q(>888rM;Kdu&6*+Cb+~g2xzXF6U*-nCPPz4MZ9BMpq+5pq{2c`l{1cP zyZwm~>R}n3rfJ`m#byb(w(GtW{4Z@p$q|SAj9NmhgbK|*S_0=Xxh$y( zt>HS1Zr?7d{9KS?6`$>L2kg~~mRcQEqvDq4w3>TsgIQlHN?X*QCyX)R+ZxY#NA9fU zwJ@E)7L^zNx4=wF)}?B9o6TGe2Yhw|)}&FJ)M({yT4D8AsAK;_FMorc2O|??2!;E! zwZ^SyE{i_cW%UT=C4g>sx^jxlKEO4j-A8Z z9uWzE{CarBgrt}Y~|5HrVVE%o!<@>zj6V9U9=IvuEIuRYoLwxKKGQH*68yw(* zalorw&X#lu_t$PKIP{uzxs(5-2)I#)kzGn|#CI&c0&8B$Ww-TSX+k9(+z?0IyL}Kn zwrW3uIF!_Ib|(yiI0S-J6%m9%t%|pnNZNBOCp4KdOgRaD#VCEG@0QE1*pN=m4+?#biKh#EZzCOEvoO6kl_$n|L@yaX^#WD6h`p;u!1nk~FDALY zr3Wkk6S86qiPh)Xo0_rQyu6Pn8V&ymf?WhE=iPM6@5kq^)*z=k-o{LJ=9K8$#dJH%o28gXD0+~c&~XNr{Z)muf4o z(vTi0I={&CdH4b68qeLAFY9XaDW*3KxaZgjqIff0$Gl$fJmV7L`>bd!vi_Xj1!97q zD0s~OAwf-H{9Ko%)v&&ANztCoyyasU-H~MX7nn&t_N(U~+>`L@C}*V(P`=BCi5yti zZ6MRPJzw$w=FtG=-u>nz`_rYh6BW5o8*ifOFLIl~%g3}y0GI!Uo#k&;>>YZ3{&dpX~xoHEOnbZhJPg3t4{^ zjbE+VTPZCfY`@Y<0u#Y6a*RVJXb`8bsk;KnlB;snLO}u}oz0NGBw^XxV6}9=eVLa0 z2V0{9AmV#cXgxChrhCtJ)t+17J_}X4kKtbLyaxh1;0i2)s&|!$DaBbuaeO4( z(4rM^ys5b_CST;}SUK&Ku}y4 z2MNyot(Sanl+qWL>me7A-CFqb*#Qg>lq=as1d4>`yPnXgAq56Uu&Z3Rr~@_HfXbfon!4Pcqm>x1g!GZAwGJ`0mo>Dd9UAGr56mg7Z*&p4BI|=bdki zRJ|JW;)&fuH?Ha=bft_q^=UxW1OUWM96kyx`_hU=Kug=8I`px9#EWm3dkX|WxJCbSCf;BH*@8i#iiWRt~X4);>7A`m943zXJc0z?5i>(x@Z3> zeT=QuJF!a^(Ek18!t3_8IEz7-hNIGqscz)g_j{h(Ps(m+K$lHg$RMUzQbvxQ3Tfaj&3?CT;h#e17WW1RobcnbecP`USDIm zYS~_MJ3tgjBoRJFj}CTMafp#OZ#@;lBp92jeWvd^%X5MKnwOBd=mImXYiSQn&Nd%l z1@(Hygl&(MF2Ca39cfr|+Prf-z%W>lGpxr;%g^%(c8}xY6hj0C2a@?@F5RF0%6oy< zwU%O-%i?VHuILv{M>Xi%^S*cn9lpT-#MUN3EQv&)9xwx*a{#>f*oKRNJs(^ZeOSJz zAKy2}M|&BLBpZq(Q;<*NTt<}1PCqa>SgPcs(f}v~AfT>I38g6Ee zCJ`~wpNzuB&P;H*tWr0VVr@vA&x;Mr}wf(7$sY&R%DJHMH2sD=eqIPhOS$V?JnAw zyvOoWT++_kxa=I`I=%?Ea~Z`jbj?H(sow;(=_m0MVYCakylM!8u1>ZuibPn$I3`%k zv;dmqo&f7phvyWBVFI)u0=kGFy_Rvn7gR=&bls3(d2i$D?iuvN%S+Bv+%d|l8(}ig zHNwfr&FqNFDGoz-@0jgOSN37Sh0s<>brDH^dn1iK(f%FmG$d&0(|e6I<-~2uZ;Al{ z?K0s$9HI@oqPFHPm|scY^>lqu2#(lYxcu&o4}tvGs|0FmSn?7c+7vx-M{HAyFHh{^sP5GAC$H-HAPsTdY+mJG} z>Hk&mZ32XRFEDeWKn=gyVcwZ?Vk4%zDlD?rC9o}%bghG?!1d@BxcDLR*K9uz2agi+ zQJ!Y(FyZ|}mH^M}8p!($Hy5jpQuDbb>U z>>}lvWD=_?<(sE}Buu`gYjGED3SP?)_xV{w-R( >i>pxMRbrt=*2uN8FJ8`uVc~ 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

  • 2YI4s+ETc0iLI57z; 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 diff --git a/desktop/shell.go b/desktop/shell.go index 740c9fa..c8e7c3d 100644 --- a/desktop/shell.go +++ b/desktop/shell.go @@ -72,7 +72,7 @@ func (a *app) sidebar(c *ui.Context) { t := c.Theme() ui.Column(c).Width(196).Background(t.Surface).Shrink(0).BorderWidth(0, 1, 0, 0).BorderColor(t.Border).Children(func() { ui.Row(c).Padding(18, 20, 10).Gap(8).AlignItems(ui.Center).Children(func() { - ui.Icon(c, iconLoops).FontSize(16) + ui.Icon(c, iconLoopable).FontSize(18) ui.Text(c, "Loopable").FontSize(15).Bold() }) path := a.router.Path() diff --git a/desktop/tray.png b/desktop/tray.png new file mode 100644 index 0000000000000000000000000000000000000000..6190bd1b5da6868a503ff4971282f24a69c9744c GIT binary patch literal 930 zcmV;T16}-yP)h}oBah)BpKm;XTt{@W zP2>QnBu~jMGMa2ee6mx?Eph=KN;-$7n#dGbV%dL_o#Y7oGz{j8O$gej$y(Cx3QSto zWW=^vwq{a6uEAedNNK1omF63H;0jDK!o@H@`M{7(7Ew8QiOjSDW-1g|e{%}?qy;7! zVW|kSEn5{5jE17uFq=wM$krcb-k;Y3T_Y@`Yk%W9vL5N^23c&`Mv|7e09Tb*ZMEfR z+ky178K(S&wMq+cCA`vV%W7_M#LOkz1Iiq}AJDz5RcQgPjic0R*E{@Aq5E_>Nj4}k zcVJ3dr)Da?>IlpH9%9)V6o2`ksJUHDCMj`^R$D!uY6*{uuzgqjXDNN9O5YEwJs3(} zTEc}9wy#KEQG+ld&RI)1YH<}Q{*Ms7wYCR+aT*h0YmIZ(5|+aL(Xy2*{;$ZM2)o#t zl(-VB{T@oft}5{mUF)LUD(V#f{VS~*1+k*5|9bvnu1tdAG$z3dbw3K7u447Ri8_CH`4p2iwwkL4xj!AAD zwN_g`;2g4(BwLTJ$%^*60+S>xtG~vw$$8;984rJLg1K#sv+Qyaxa2U~VVY(ho=D<{u%80(kpug4eH@p&-bA>F3g)#kd=aWmN%uA`ee_-bNbeM;hxKr2* zm~yG!G&<$`kkXeNC+m=oZo<_0wxj{RZ)>v6$`wWuuRwN^#Vp6lzdk{kJ_g=~Ss*e^ z*t{m0E=E?-reU15;!7!Ljwalt#j~iUaz=9Aui2qH>XN zPL=