diff --git a/packages/app/src/providers/models/selection.tsx b/packages/app/src/providers/models/selection.tsx index 5ad40f52dd6f..dbc1c342cbc7 100644 --- a/packages/app/src/providers/models/selection.tsx +++ b/packages/app/src/providers/models/selection.tsx @@ -23,17 +23,20 @@ const ModelKeySchema = Schema.Struct({ modelID: Schema.String, variant: Schema.optional(Schema.String), }) + export type ModelKey = typeof ModelKeySchema.Type const ChoiceSchema = Schema.Struct({ model: Persistence.optional(ModelKeySchema), variant: Persistence.optional(Schema.NullOr(Schema.String)), }) + const StateSchema = Schema.Struct({ ...ChoiceSchema.fields, agent: Persistence.optional(Schema.String), choices: Persistence.optional(Schema.Record(Schema.String, ChoiceSchema)), }) + type State = typeof StateSchema.Type const SessionsSchema = Schema.Record( @@ -61,12 +64,14 @@ export const ModelSelectionSchema = Persistence.migrate( ) const WORKSPACE_KEY = "__workspace__" + const handoff = new Map() const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id) const clone = (value: State | undefined) => { if (!value) return + return { ...value, model: value.model ? { ...value.model } : undefined, @@ -86,11 +91,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const configuredModel = useConfiguredModel() const id = createMemo(() => params.id || undefined) + const list = createMemo(() => normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter( (item) => item.mode !== "subagent" && !item.hidden, ), ) + const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list())) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) @@ -111,13 +118,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const validModel = (model: ModelKey) => { const provider = providers.all().get(model.providerID) + return !!provider?.models[model.modelID] && connected().has(model.providerID) } const firstModel = (...items: Array<() => ModelKey | undefined>) => { for (const item of items) { const model = item() + if (!model) continue + if (validModel(model)) return model } } @@ -128,30 +138,39 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ createEffect(() => { const items = list() + if (items.length === 0) { if (store.current !== undefined) setStore("current", undefined) + return } + if (items.some((item) => item.name === store.current)) return setStore("current", items[0]?.name) }) const scope = createMemo(() => { const session = id() + if (!session) return store.draft ?? store.promoting + return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk().directory, session)) }) createEffect(() => { const session = id() + if (!session) return const key = handoffKey(serverSDK.scope, sdk().directory, session) const next = handoff.get(key) + if (!next) return + if (saved.session[session] !== undefined) { handoff.delete(key) setStore("promoting", undefined) + return } @@ -169,15 +188,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const defaultModel = () => { for (const provider of providers.connected()) { const first = Object.values(provider.models)[0] + if (!first) continue const model = { providerID: provider.id, modelID: first.id } + if (validModel(model)) return model } } const fallback = createMemo(() => firstModel(configuredModel, recentModel, defaultModel)) + const durable = () => { const session = id() + return session ? data.session.get(session) : undefined } @@ -189,43 +212,55 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, set(name: string | undefined) { const item = pickAgent(name) + if (!item) { setStore("current", undefined) + return } batch(() => { const previous = snapshot() + if (previous.agent === item.name) return const prev = scope() - const choices = { - ...prev?.choices, - ...(previous.agent ? { [previous.agent]: { model: previous.model, variant: previous.variant } } : {}), - } + + const choices = { ...prev?.choices } + + if (previous.agent) choices[previous.agent] = { model: previous.model, variant: previous.variant } + setStore("current", item.name) + const next = { agent: item.name, model: choices[item.name]?.model, variant: choices[item.name]?.variant, choices, } satisfies State + write(next) // Pin both choices while the agent and model acknowledgments arrive separately. const selected = current() + if (selected) model.set({ providerID: selected.provider.id, modelID: selected.id }) }) }, move(direction: 1 | -1) { const items = list() + if (items.length === 0) { setStore("current", undefined) + return } let next = items.findIndex((item) => item.name === agent.current()?.name) + direction + if (next < 0) next = items.length - 1 + if (next >= items.length) next = 0 const item = items[next] + if (!item) return agent.set(item.name) }, @@ -233,26 +268,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const current = createMemo(() => { if (!configuredModel.ready()) return + const item = firstModel( () => scope()?.model, () => { const session = durable() + if (session?.agent && session.agent !== agent.current()?.name) return const model = session?.model + return model && { providerID: model.providerID, modelID: model.id } }, () => agent.current()?.model, fallback, ) + if (!item) return + return models.find(item) }) const configured = () => { const item = agent.current() const model = current() + if (!item || !model) return const global = configuredModel() + return ( getConfiguredAgentVariant({ agent: { model: item.model, variant: item.variant }, @@ -267,15 +309,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const selected = () => { const draft = scope() + if (draft?.model && validModel(draft.model)) return draft.variant const session = durable() + if (session?.agent && session.agent !== agent.current()?.name) return const value = session?.model + if (value && validModel({ providerID: value.providerID, modelID: value.id })) return value.variant ?? null } const snapshot = () => { const selected = current() + return { agent: agent.current()?.name, model: selected ? { providerID: selected.provider.id, modelID: selected.id } : undefined, @@ -291,15 +337,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } satisfies State const session = id() + if (session) { setSaved("session", session, state) + return } + setStore("draft", state) } const recent = createMemo(() => models.recent.list().filter(validModel).map(models.find).filter(Boolean)) const pending = new Map() + const sameSelection = (a: State, b: State) => a.agent === b.agent && a.model?.providerID === b.model?.providerID && @@ -309,7 +359,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const reconcile = (sessionID: string) => { const expected = pending.get(sessionID) const session = data.session.get(sessionID) + if (!expected || !session?.model) return + if ( !sameSelection(expected, { agent: session.agent, @@ -320,9 +372,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return pending.delete(sessionID) const draft = saved.session[sessionID] + if (id() !== sessionID || !draft || !sameSelection(draft, expected)) return setSaved("session", sessionID, { agent: undefined, model: undefined, variant: undefined }) } + onCleanup(serverSDK.event.on("session.model.selected", (event) => reconcile(event.data.sessionID))) onCleanup(serverSDK.event.on("session.agent.selected", (event) => reconcile(event.data.sessionID))) onCleanup( @@ -340,6 +394,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ trackSessionCommit(sessionID: string, selection: { agent: string; model: ModelKey; variant?: string }) { pending.set(sessionID, selection) reconcile(sessionID) + return () => { if (pending.get(sessionID) === selection) pending.delete(sessionID) } @@ -347,14 +402,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ cycle(direction: 1 | -1) { const items = recent() const item = current() + if (!item) return const index = items.findIndex((entry) => entry?.provider.id === item.provider.id && entry?.id === item.id) let next = index === -1 ? (direction === 1 ? 0 : items.length - 1) : index + direction + if (next < 0) next = items.length - 1 + if (next >= items.length) next = 0 const entry = items[next] + if (!entry) return model.set({ providerID: entry.provider.id, modelID: entry.id }) }, @@ -364,10 +423,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const previous = current() const same = item && previous?.provider.id === item.providerID && previous.id === item.modelID write({ model: item, variant: same ? (model.variant.current() ?? null) : undefined }) + if (!item) return + // A session draft owns its variant even when preferences change in another session. if (id() && !same) write({ variant: model.variant.current() ?? null }) models.setVisibility(item, true) + if (!options?.recent) return models.recent.push(item) }) @@ -383,6 +445,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ selected, current() { const model = current() + return resolveModelVariant({ variants: this.list(), selected: this.selected(), @@ -392,12 +455,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, list() { const item = current() + if (!item?.variants) return [] + return Object.keys(item.variants) }, set(value: string | undefined) { batch(() => { const model = current() + if (!model) return write({ model: { providerID: model.provider.id, modelID: model.id }, variant: value ?? null }) models.variant.set({ providerID: model.provider.id, modelID: model.id }, value) @@ -405,6 +471,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, cycle() { const items = this.list() + if (items.length === 0) return this.set( cycleModelVariant({ @@ -428,11 +495,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, promote(dir: string, session: string, state?: State) { const next = clone(state ?? snapshot()) + if (!next) return // Creation already owns the active selection; keep only agent memory once it is in the read model. // Otherwise a first-message command's configured overrides would stay hidden behind this handoff. const created = data.session.get(session) const selection = created?.model + const committed = selection && sameSelection(next, { @@ -442,6 +511,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) ? { choices: next.choices } : next + const key = handoffKey(serverSDK.scope, dir, session) handoff.set(key, committed) @@ -454,10 +524,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, restore(msg: { sessionID: string; agent: string; model: ModelKey }) { const session = id() + if (!session) return + if (msg.sessionID !== session) return + if (durable()?.model) return + if (saved.session[session] !== undefined) return + if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return setSaved("session", session, { @@ -468,6 +543,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }, }, } + return result }, }) diff --git a/packages/app/src/runtime/persistence/schema.test.ts b/packages/app/src/runtime/persistence/schema.test.ts index 99fa06786a56..f492bd763e96 100644 --- a/packages/app/src/runtime/persistence/schema.test.ts +++ b/packages/app/src/runtime/persistence/schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { Effect, Schema, SchemaGetter } from "effect" +import { Effect, Predicate, Schema, SchemaGetter } from "effect" import { Persistence } from "./schema" describe("persistence schemas", () => { @@ -12,6 +12,7 @@ describe("persistence schemas", () => { }), { enabled: true, appearance: { width: 240, font: "default" }, variant: "high" }, ) + const decode = Schema.decodeUnknownSync(schema) expect(decode({ appearance: { width: 300 } })).toEqual({ enabled: true, @@ -32,6 +33,7 @@ describe("persistence schemas", () => { test("legacy migration observes missing fields before initial defaults are applied", () => { const current = Persistence.struct({ mode: Schema.Literals(["compact", "full"]), enabled: Schema.Boolean }) + const stored = Schema.Struct({ mode: Schema.optional(Schema.Unknown), expanded: Schema.optional(Schema.Boolean), @@ -45,6 +47,7 @@ describe("persistence schemas", () => { encode: SchemaGetter.passthrough(), }), ) + const schema = Persistence.withInitial(Persistence.migrate(current, stored), { mode: "compact", enabled: true }) const decode = Schema.decodeUnknownSync(schema) expect(decode({ expanded: true, enabled: false })).toEqual({ mode: "full", enabled: false }) @@ -58,6 +61,7 @@ describe("persistence schemas", () => { amount: Schema.NumberFromString.check(Schema.isFinite()), items: Schema.mutable(Schema.Array(Schema.String)), }) + const schema = Persistence.withInitial(current, { amount: 7, items: ["initial"] }) const decode = Schema.decodeUnknownSync(schema) expect(decode({ amount: "12", items: [] })).toEqual({ amount: 12, items: [] }) @@ -69,6 +73,7 @@ describe("persistence schemas", () => { const schema = Persistence.struct({ enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), }) + const decode = Schema.decodeUnknownSync(schema) expect(decode({})).toEqual({ enabled: true }) expect(decode({ enabled: undefined })).toEqual({ enabled: true }) @@ -84,6 +89,7 @@ describe("persistence schemas", () => { label: Persistence.fallback(Schema.String, () => "default"), amount: Persistence.fallback(Schema.NumberFromString.check(Schema.isFinite()), () => 7), }) + const decode = Schema.decodeUnknownSync(schema) expect(decode({})).toEqual({ enabled: true, label: "default", amount: 7 }) expect(decode({ enabled: "false", label: "saved", amount: "invalid" })).toEqual({ @@ -142,14 +148,16 @@ describe("persistence schemas", () => { test("recovers and migrates individual array entries", () => { const current = Schema.Struct({ name: Schema.String }) + const schema = Persistence.array( Schema.Union([current, Schema.String]).pipe( Schema.decodeTo(current, { - decode: SchemaGetter.transform((value) => (typeof value === "string" ? { name: value } : value)), + decode: SchemaGetter.transform((value) => (Predicate.isString(value) ? { name: value } : value)), encode: SchemaGetter.passthrough(), }), ), ) + const decode = Schema.decodeUnknownSync(schema) const value = decode(["old", { name: "new" }, null, { name: false }]) expect(value).toEqual([{ name: "old" }, { name: "new" }]) diff --git a/packages/app/src/runtime/persistence/schema.ts b/packages/app/src/runtime/persistence/schema.ts index bab04ef3940b..0818b8609b2c 100644 --- a/packages/app/src/runtime/persistence/schema.ts +++ b/packages/app/src/runtime/persistence/schema.ts @@ -23,10 +23,13 @@ export function withInitial>( initial: NoInfer, ) { const schema = isMigrated(definition) ? definition.current : definition + const read = isMigrated(definition) ? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" }) : Result.succeed + const encode = Schema.encodeUnknownSync(schema) + return Schema.Unknown.pipe( Schema.decode({ decode: SchemaGetter.transformOrFail((value) => @@ -39,25 +42,37 @@ export function withInitial>( } // Object-level codecs own their recovery. Plain structs can recover fields independently. +// SAFETY: stored values and their defaults are untyped trees walked against `ast`; `withInitial` decodes the +// recovered and merged result with the current schema before anyone reads it. +// oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- see SAFETY above function recover(ast: SchemaAST.AST, value: unknown, initial: unknown): unknown { if (value === undefined) return initial - if (ast._tag === "Objects" && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) { + + if (SchemaAST.isObjects(ast) && !ast.encoding && ast.indexSignatures.length === 0 && Predicate.isObject(value)) { return Object.fromEntries( ast.propertySignatures.flatMap((field) => { const defaults = Predicate.isObject(initial) ? initial[field.name] : undefined const next = recover(field.type, value[field.name], defaults) + if (next === undefined && !Object.hasOwn(value, field.name) && defaults === undefined) return [] + return [[field.name, next]] }), ) } + const decoded = Schema.decodeUnknownOption(Schema.make>(ast))(value) + return Option.isSome(decoded) ? decoded.value : initial } +// SAFETY: like `recover`, merges untyped trees that `withInitial` decodes with the current schema afterwards. +// oxlint-disable-next-line anti-slop/no-unknown-parameters, anti-slop/no-unknown-returns -- see SAFETY above function merge(initial: unknown, value: unknown): unknown { if (value === undefined) return initial + if (!Predicate.isObject(initial) || !Predicate.isObject(value)) return value + return Object.fromEntries( [...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]), ) @@ -66,11 +81,13 @@ function merge(initial: unknown, value: unknown): unknown { // Unlike a decoding default, a fallback also replaces invalid persisted values. export function fallback>(schema: S, value: () => S["Type"]) { const defaulted = Schema.withDecodingDefaultType(Effect.sync(value))(schema) + return Schema.catchDecoding(() => Effect.sync(() => Option.some(value())))(defaulted) } export function optional>(schema: S) { const field = Schema.optional(schema) + return Schema.catchDecoding(() => Effect.succeed(Option.none()))(field) } @@ -80,6 +97,7 @@ export function struct(fields: Fields export function record>(schema: S) { const entries = Schema.Record(Schema.String, Schema.mutableKey(schema)) + return fallback(entries, () => Schema.decodeUnknownSync(entries)({})) } @@ -87,6 +105,7 @@ export function record>(schem export function array>(schema: S) { const decode = Schema.decodeUnknownOption(schema) const encode = Schema.encodeSync(schema) + return fallback( Schema.Array(Schema.Unknown).pipe( Schema.decodeTo(Schema.mutable(Schema.Array(Schema.toType(schema))), { diff --git a/packages/app/src/runtime/server/persistence.ts b/packages/app/src/runtime/server/persistence.ts index fefc54aa0e78..caffeebdd96d 100644 --- a/packages/app/src/runtime/server/persistence.ts +++ b/packages/app/src/runtime/server/persistence.ts @@ -1,4 +1,4 @@ -import { Effect, Option, Schema, SchemaGetter } from "effect" +import { Effect, Option, Predicate, Schema, SchemaGetter } from "effect" import { Persistence } from "@/runtime/persistence/schema" export const ServerKey = Schema.String.pipe(Schema.brand("ServerConnection.Key")) @@ -19,8 +19,10 @@ export const ServerHttp = Persistence.struct({ const StoredServer = Schema.Union([ServerHttp, ServerHttpBase, Schema.String]).pipe( Schema.decodeTo(ServerHttp, { decode: SchemaGetter.transform((value) => { - if (typeof value === "string") return { type: "http", http: { url: value } } + if (Predicate.isString(value)) return { type: "http", http: { url: value } } + if ("http" in value) return value + return { type: "http", http: value } }), encode: SchemaGetter.transform((value) => value), @@ -33,7 +35,9 @@ const ProjectList = Persistence.array( expanded: Persistence.fallback(Schema.Boolean, () => true), }), ) + const Projects = Persistence.record(ProjectList) + const LastProject = Persistence.record(Schema.String.pipe(Schema.catchDecoding(() => Effect.succeed(Option.none())))) const State = Persistence.struct({ @@ -57,12 +61,15 @@ export function serverState(canonicalLocalServer: () => string | undefined = () Schema.decode({ decode: SchemaGetter.transform((value) => { const canonical = canonicalLocalServer() + if (!canonical || canonical === "local") return value const previous = value.projects[canonical] const last = value.lastProject[canonical] + if (!previous && last === undefined) return value const projects = { ...value.projects } + if (previous) { const local = projects.local ?? [] const worktrees = new Set(local.map((project) => project.worktree)) @@ -71,16 +78,20 @@ export function serverState(canonicalLocalServer: () => string | undefined = () ...previous.filter((project) => { if (worktrees.has(project.worktree)) return false worktrees.add(project.worktree) + return true }), ] delete projects[canonical] } + const lastProject = { ...value.lastProject } + if (last !== undefined) { lastProject.local ??= last delete lastProject[canonical] } + return { ...value, projects, lastProject } }), encode: SchemaGetter.transform((value) => value), diff --git a/packages/desktop/src/main/files/attachment-picker.ts b/packages/desktop/src/main/files/attachment-picker.ts index 11a460dce8dd..1419a55d4d03 100644 --- a/packages/desktop/src/main/files/attachment-picker.ts +++ b/packages/desktop/src/main/files/attachment-picker.ts @@ -13,15 +13,19 @@ export function createPickedFileAuthorizations( add(sender: number, paths: string[]) { const token = randomUUID() selections.set(token, { sender, paths: new Set(paths), remaining: MAX_ATTACHMENT_BYTES }) + return token }, read: Effect.fn("DesktopFiles.readPickedFile")(function* (sender: number, token: string, path: string) { const selection = selections.get(token) + if (selection?.sender !== sender || !selection.paths.delete(path)) throw new Error(nativeT("desktop.picker.error.notSelected")) const bytes = yield* read(path, selection.remaining) selection.remaining -= bytes.byteLength + if (selection.paths.size === 0) selections.delete(token) + return bytes }), release(sender: number, token: string) { @@ -32,6 +36,7 @@ export function createPickedFileAuthorizations( export function assertAttachmentBudget(files: { size: number }[]) { const total = files.reduce((sum, file) => sum + file.size, 0) + if (total <= MAX_ATTACHMENT_BYTES) return throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 })) } @@ -42,16 +47,20 @@ export function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES const fs = yield* FileSystem.FileSystem const file = yield* fs.open(filePath, { flag: "r" }) const info = yield* file.stat + if (info.size > FileSystem.Size(maxBytes)) throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 })) const bytes = new Uint8Array(Number(info.size)) let offset = 0 + while (offset < bytes.byteLength) { const read = Number(yield* file.read(bytes.subarray(offset))) + if (read === 0) break offset += read } + return bytes.buffer.slice(0, offset) }), ) diff --git a/packages/desktop/src/main/ipc-transport.test.ts b/packages/desktop/src/main/ipc-transport.test.ts index f48eca43b70d..754690389986 100644 --- a/packages/desktop/src/main/ipc-transport.test.ts +++ b/packages/desktop/src/main/ipc-transport.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { EventEmitter } from "node:events" import { MessageChannel } from "node:worker_threads" import type { MessagePortMain, WebContents } from "electron" -import { Effect, Layer, ManagedRuntime, Schema, Stream } from "effect" +import { Effect, Layer, ManagedRuntime, Predicate, Schema, Stream } from "effect" import { Rpc, RpcGroup, RpcMessage, RpcServer } from "effect/unstable/rpc" import { Transferable } from "effect/unstable/workers" import { omitUndefined } from "../shared/ipc-transport" @@ -13,13 +13,16 @@ describe("desktop RPC transport", () => { test("decodes renderer payloads whose optional fields are undefined", async () => { let received: unknown const rpcs = RpcGroup.make(FilesOpenFilePicker) + const handlers = rpcs.toLayer({ FilesOpenFilePicker: ({ options }) => Effect.sync(() => { received = options + return null }), }) + const live = RpcServer.layer(rpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive)) const runtime = ManagedRuntime.make(live) const handoff = await runtime.runPromise(IpcPortHandoff) @@ -30,13 +33,10 @@ describe("desktop RPC transport", () => { // Structured clone keeps a present-but-undefined key, so it reaches the JSON codec. const rejected = await call(channel.port2, 0, "FilesOpenFilePicker", payload) - expect(rejected.exit).toMatchObject({ - _tag: "Failure", - cause: [{ _tag: "Die", defect: expect.stringContaining('["options"]["title"]') }], - }) + expect(rejected.exit).toMatchObject(died(expect.stringContaining('["options"]["title"]'))) const accepted = await call(channel.port2, 1, "FilesOpenFilePicker", omitUndefined(payload)) - expect(accepted.exit).toEqual({ _tag: "Success", value: null }) + expect(accepted.exit).toEqual(success(null)) expect(received).toEqual({ multiple: true, defaultPath: "C:\\project" }) channel.port2.close() @@ -56,13 +56,16 @@ describe("desktop RPC transport", () => { test("keeps multiple renderer ports independent", async () => { let received: unknown + const handlers = TestRpcs.toLayer( Effect.gen(function* () { const handoff = yield* IpcPortHandoff + return TestRpcs.of({ "test.focused": (_request, context) => Effect.succeed(handoff.sender(context.client.id)?.id === 1), "test.blob.put": ({ data }) => { received = data + return Effect.succeed([...data].join(",")) }, "test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])), @@ -70,6 +73,7 @@ describe("desktop RPC transport", () => { }) }), ) + const live = RpcServer.layer(TestRpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive)) const runtime = ManagedRuntime.make(live) const handoff = await runtime.runPromise(IpcPortHandoff) @@ -82,34 +86,36 @@ describe("desktop RPC transport", () => { call(first.port2, 0, "test.focused", null), call(second.port2, 0, "test.focused", null), ]) - expect(focused.exit).toEqual({ _tag: "Success", value: true }) - expect(unfocused.exit).toEqual({ _tag: "Success", value: false }) + + expect(focused.exit).toEqual(success(true)) + expect(unfocused.exit).toEqual(success(false)) const put = await call(first.port2, 1, "test.blob.put", omitUndefined({ data: new Uint8Array([2, 7, 1]) })) - expect(put.exit).toEqual({ _tag: "Success", value: "2,7,1" }) + expect(put.exit).toEqual(success("2,7,1")) // Binary payloads arrive as bytes, not as base64 text or a plain object. expect(received).toBeInstanceOf(Uint8Array) - expect((await call(first.port2, 2, "test.blob.get", null)).exit).toEqual({ - _tag: "Success", - value: new Uint8Array([3, 1, 4]), - }) + expect((await call(first.port2, 2, "test.blob.get", null)).exit).toEqual(success(new Uint8Array([3, 1, 4]))) expect((await call(first.port2, 3, "test.events", null)).chunks).toEqual([ - { _tag: "TestEvent", value: "session.new" }, + Schema.encodeSync(TestEvent)(new TestEvent({ value: "session.new" })), ]) const reloaded = new MessageChannel() handoff.bind(sender(1), serverPort(reloaded.port1)) + const [reloadedFocused, stillUnfocused] = await Promise.all([ call(reloaded.port2, 0, "test.focused", null), call(second.port2, 1, "test.focused", null), ]) - expect(reloadedFocused.exit).toEqual({ _tag: "Success", value: true }) - expect(stillUnfocused.exit).toEqual({ _tag: "Success", value: false }) + + expect(reloadedFocused.exit).toEqual(success(true)) + expect(stillUnfocused.exit).toEqual(success(false)) + for (const port of [first.port2, second.port2, reloaded.port2]) port.close() await runtime.dispose() }) }) class TestEvent extends Schema.TaggedClass()("TestEvent", { value: Schema.String }) {} + const TestRpcs = RpcGroup.make( Rpc.make("test.focused", { success: Schema.Boolean }), Rpc.make("test.blob.put", { payload: { data: Transferable.Uint8Array }, success: Schema.String }), @@ -117,28 +123,47 @@ const TestRpcs = RpcGroup.make( Rpc.make("test.events", { success: TestEvent, stream: true }), ) +// SAFETY: these are the RPC wire format's plain encoded messages and exits, which the tests post and expect as is. +/* oxlint-disable anti-slop-effect/no-manual-tagged-construction -- see SAFETY above */ +function success(value: A) { + return { _tag: "Success", value } as const +} + +function died(defect: A) { + return { _tag: "Failure", cause: [{ _tag: "Die", defect }] } as const +} + // Speaks the wire format the way src/renderer/ipc-client.ts does: post a request, ack each chunk, // and settle on the exit. The payload is posted as given so a test can send what omitUndefined drops. -function call(port: MessageChannel["port2"], id: number, tag: string, payload: unknown) { +function call(port: MessageChannel["port2"], id: number, tag: string, payload: RpcMessage.RequestEncoded["payload"]) { const chunks: unknown[] = [] + return new Promise<{ chunks: unknown[]; exit: RpcMessage.ResponseExitEncoded["exit"] }>((resolve) => { const onMessage = (message: RpcMessage.FromServerEncoded) => { if (!("requestId" in message) || Number(message.requestId) !== id) return - if (message._tag === "Chunk") { + + if (Predicate.isTagged(message, "Chunk")) { chunks.push(...message.values) port.postMessage({ _tag: "Ack", requestId: message.requestId } satisfies RpcMessage.AckEncoded) + return } + port.off("message", onMessage) resolve({ chunks, exit: message.exit }) } + port.on("message", onMessage) port.postMessage({ _tag: "Request", id, tag, payload, headers: [] }) }) } +/* oxlint-enable anti-slop-effect/no-manual-tagged-construction */ function sender(id: number) { const events = new EventEmitter() + + // SAFETY: the transport reads only a sender's `id` and `isDestroyed`, and its `destroyed` event. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above return { id, isDestroyed: () => false, @@ -148,27 +173,38 @@ function sender(id: number) { } function serverPort(port: MessageChannel["port1"]) { - const listeners = new Map<(event: Electron.MessageEvent) => void, (data: unknown) => void>() - return { + const listeners = new Map<(event: Electron.MessageEvent) => void, (data: Electron.MessageEvent["data"]) => void>() + + const fake = { on(event: string, listener: (event: Electron.MessageEvent) => void) { if (event !== "message") { port.on(event, listener) + return } - const wrapped = (data: unknown) => listener({ data } as Electron.MessageEvent) + + // SAFETY: the transport reads only the `data` of a message event. + const wrapped = (data: Electron.MessageEvent["data"]) => listener({ data } as Electron.MessageEvent) listeners.set(listener, wrapped) port.on("message", wrapped) }, off(event: string, listener: (event: Electron.MessageEvent) => void) { if (event !== "message") { port.off(event, listener) + return } + const wrapped = listeners.get(listener) + if (wrapped) port.off("message", wrapped) }, postMessage: port.postMessage.bind(port), start: port.start.bind(port), close: port.close.bind(port), - } as unknown as MessagePortMain + } + + // SAFETY: the transport uses only these methods of a port. + // oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above + return fake as unknown as MessagePortMain } diff --git a/packages/desktop/src/main/ipc-transport.ts b/packages/desktop/src/main/ipc-transport.ts index 91e6e84acce8..b9e3bf3864c1 100644 --- a/packages/desktop/src/main/ipc-transport.ts +++ b/packages/desktop/src/main/ipc-transport.ts @@ -39,8 +39,10 @@ export const IpcServerProtocolLive = Layer.unwrap( const disconnect = Effect.fnUntraced(function* (id: number) { const binding = bindings.get(id) + if (!binding) return bindings.delete(id) + if (senderBindings.get(binding.sender.id) === id) senderBindings.delete(binding.sender.id) binding.port.off("message", binding.onMessage) binding.port.off("close", binding.onClose) @@ -52,16 +54,23 @@ export const IpcServerProtocolLive = Layer.unwrap( const bind = Effect.fnUntraced(function* (sender: WebContents, port: MessagePortMain) { const previous = senderBindings.get(sender.id) + if (previous !== undefined) yield* disconnect(previous) + if (sender.isDestroyed()) { port.close() + return } const id = nextClientId++ + const onMessage = (event: Electron.MessageEvent) => { + // SAFETY: the other end of this port is the renderer's ipc-client, which posts only RPC client + // messages; the RPC server decodes each request's payload with that RPC's schema. Queue.offerUnsafe(inbound, [id, event.data as RpcMessage.FromClientEncoded] as const) } + const onClose = () => runFork(disconnect(id)) const unbindEvents = yield* bindIpcEvents(sender.id) const binding = { id, sender, port, onMessage, onClose, unbindEvents } diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 01308f3ceaf3..e09f6cdf0d15 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -22,6 +22,7 @@ import { DesktopCli } from "./service/desktop-cli" import { getLastFocusedWindow } from "./windows" const services = Layer.mergeAll(DesktopFiles.layer, Extensions.layer) + const handlers = Layer.mergeAll( appHandlers, storageHandlers, @@ -31,6 +32,7 @@ const handlers = Layer.mergeAll( eventHandlers, extensionHandlers, ) + export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe( Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive), @@ -42,9 +44,11 @@ export const registerIpcHandlers = Effect.gen(function* () { const lifecycle = yield* ApplicationLifecycle.Service const desktopCli = yield* DesktopCli.Service const runFork = Effect.runForkWith(yield* Effect.context()) + const menu = { trigger: (id: string) => { const win = getLastFocusedWindow() + if (win) sendMenuCommand(win, id) }, installCli: () => runFork(showCliInstaller(desktopCli)), @@ -52,26 +56,34 @@ export const registerIpcHandlers = Effect.gen(function* () { openExternal: (url: string) => runFork(openExternalURL(url)), relaunch: lifecycle.relaunch, } - const wire = (_event: Electron.Event, win: BrowserWindow) => { + + const wire = (win: BrowserWindow) => { win.webContents.on("before-input-event", (_event, input) => { if (input.type !== "keyDown" || input.key !== "Escape") return win.webContents.send(DragCancelEvent) }) + const post = () => { if (win.isDestroyed() || win.webContents.isDestroyed()) return const channel = new MessageChannelMain() handoff.bind(win.webContents, channel.port1) win.webContents.postMessage(IpcTransportPort, null, [channel.port2]) } + win.webContents.on("did-finish-load", post) + // The first window starts loading before the layers exist and may already be done. if (!win.webContents.isLoading() && win.webContents.getURL()) post() } + + const onWindowCreated = (_event: Electron.Event, win: BrowserWindow) => wire(win) + yield* Effect.sync(() => { - app.on("browser-window-created", wire) - BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win)) + app.on("browser-window-created", onWindowCreated) + BrowserWindow.getAllWindows().forEach((win) => wire(win)) }) - yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire))) + yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", onWindowCreated))) + return { installMenu: () => createMenu(menu), } diff --git a/packages/desktop/src/renderer/ipc-client.ts b/packages/desktop/src/renderer/ipc-client.ts index 904b0592bc17..d6cb754b0963 100644 --- a/packages/desktop/src/renderer/ipc-client.ts +++ b/packages/desktop/src/renderer/ipc-client.ts @@ -11,33 +11,47 @@ import { IpcTransportPort, omitUndefined } from "../shared/ipc-transport" // out of the renderer's initial module graph is worth about a third of its startup script. type EventTag = DesktopEvent["_tag"] + type InvokeTag = Exclude + type InvokeArgs = Parameters + type InvokeResult = ReturnType extends Effect.Effect ? Value : never + type EventValue = Extract type Pending = { + // SAFETY: the reply is the RPC's success value as the trusted main process encoded it; see the note above. + // oxlint-disable-next-line anti-slop/no-unknown-parameters -- see SAFETY above readonly resolve: (value: unknown) => void - readonly reject: (error: unknown) => void + readonly reject: (cause: unknown) => void readonly chunk?: (values: ReadonlyArray) => void } const pending = new Map() -const listeners = new Map void>>() + +const listeners = new Map void>>() + +// SAFETY: pagehide only waits for these callbacks to settle and ignores what they resolve to. +// oxlint-disable-next-line anti-slop/no-unknown-returns -- see SAFETY above const beforeDispose = new Set<() => Promise | void>() + let nextId = 0 const port = new Promise((resolve) => { const onMessage = (event: MessageEvent) => { if (event.source !== window || event.data !== IpcTransportPort) return const value = event.ports[0] + if (!value) return window.removeEventListener("message", onMessage) + // SAFETY: the port comes from the trusted main process, whose RPC server posts only its own messages. value.addEventListener("message", (message) => receive(value, message.data as RpcMessage.FromServerEncoded)) value.start() resolve(value) } + window.addEventListener("message", onMessage) }) @@ -49,15 +63,20 @@ window.addEventListener( ) void request("DesktopEvents", null, (values) => { + // SAFETY: DesktopEvents streams desktop events, whose encoded fields are JSON-native and read as decoded. for (const value of values as ReadonlyArray) listeners.get(value._tag)?.forEach((fn) => fn(value)) }) +// SAFETY: as for `beforeDispose`, the callback's result is only awaited, never read. +// oxlint-disable-next-line anti-slop/no-unknown-returns -- see SAFETY above export function onBeforeDispose(callback: () => Promise | void) { beforeDispose.add(callback) + return () => beforeDispose.delete(callback) } export function invoke(tag: Tag, ...payload: InvokeArgs): Promise> { + // SAFETY: main replies with the success value of `tag`'s RPC; its encoding is JSON-native or bytes, as decoded. return request(tag, payload[0] ?? null) as Promise> } @@ -71,37 +90,55 @@ export function cancellable( payload: InvokeArgs[0], signal: AbortSignal | undefined, ): Promise> { + // SAFETY: as in `invoke`, the reply is the success value of `tag`'s RPC. return request(tag, payload ?? null, undefined, signal) as Promise> } export function listen(tag: Tag, listener: (value: EventValue) => void) { - const callback = listener as (value: unknown) => void + // SAFETY: events dispatch by tag, so this listener only receives `Tag` events. + const callback = listener as (value: DesktopEvent) => void const callbacks = listeners.get(tag) ?? new Set() callbacks.add(callback) listeners.set(tag, callbacks) + return () => { callbacks.delete(callback) + if (callbacks.size === 0) listeners.delete(tag) } } -function request(tag: string, payload: unknown, chunk?: Pending["chunk"], signal?: AbortSignal) { +// SAFETY: this side speaks the RPC wire format by hand, whose messages are plain tagged objects. Effect's Match, +// Predicate, and message constructors would load the Effect runtime into the renderer; see the note above. +/* oxlint-disable anti-slop-effect/no-manual-tag-comparison, anti-slop-effect/no-manual-tagged-construction -- see SAFETY above */ +function request( + tag: InvokeTag | "DesktopEvents", + payload: InvokeArgs[0] | null, + chunk?: Pending["chunk"], + signal?: AbortSignal, +) { const id = nextId++ + return new Promise((resolve, reject) => { if (signal?.aborted) return reject(signal.reason) + const abort = () => { if (!pending.delete(id)) return reject(signal?.reason) void port.then((p) => p.postMessage({ _tag: "Interrupt", requestId: id } satisfies RpcMessage.InterruptEncoded)) } + signal?.addEventListener("abort", abort, { once: true }) + const settle = (callback: (value: Value) => void) => (value: Value) => { signal?.removeEventListener("abort", abort) callback(value) } + pending.set(id, { resolve: settle(resolve), reject: settle(reject), chunk }) + const message: RpcMessage.RequestEncoded = { _tag: "Request", id, @@ -109,6 +146,7 @@ function request(tag: string, payload: unknown, chunk?: Pending["chunk"], signal payload: omitUndefined(payload), headers: [], } + void port.then((p) => p.postMessage(message)) }) } @@ -118,24 +156,33 @@ function receive(p: MessagePort, message: RpcMessage.FromServerEncoded) { case "Chunk": { pending.get(Number(message.requestId))?.chunk?.(message.values) p.postMessage({ _tag: "Ack", requestId: message.requestId } satisfies RpcMessage.AckEncoded) + return } + case "Exit": { const id = Number(message.requestId) const entry = pending.get(id) pending.delete(id) + if (!entry) return + if (message.exit._tag === "Success") return entry.resolve(message.exit.value) + return entry.reject(failure(message.exit.cause)) } + case "Defect": { const error = new Error("Desktop IPC defect", { cause: message.defect }) pending.forEach((entry) => entry.reject(error)) pending.clear() + return } + case "ClientProtocolError": { console.error("[desktop-ipc] protocol error", message.error) + return } } @@ -145,8 +192,12 @@ function receive(p: MessagePort, message: RpcMessage.FromServerEncoded) { // and interrupts surface as errors. function failure(cause: ReadonlyArray<{ readonly _tag: string; readonly error?: unknown; readonly defect?: unknown }>) { const failed = cause.find((item) => item._tag === "Fail") + if (failed) return failed.error const died = cause.find((item) => item._tag === "Die") + if (died) return new Error("Desktop IPC handler failed", { cause: died.defect }) + return new Error("Desktop IPC request interrupted") } +/* oxlint-enable anti-slop-effect/no-manual-tag-comparison, anti-slop-effect/no-manual-tagged-construction */ diff --git a/packages/desktop/src/shared/ipc-rpc.ts b/packages/desktop/src/shared/ipc-rpc.ts index 6cad9a24ff88..5475342e97bf 100644 --- a/packages/desktop/src/shared/ipc-rpc.ts +++ b/packages/desktop/src/shared/ipc-rpc.ts @@ -8,12 +8,19 @@ import { StorageRpcs } from "./ipc-rpc/storage" import { WindowRpcs } from "./ipc-rpc/window" export { AppRpcs } from "./ipc-rpc/app" + export { EventRpcs } from "./ipc-rpc/events" + export { ExtensionRpcs } from "./ipc-rpc/extensions" + export { FileRpcs } from "./ipc-rpc/files" + export { MenuRpcs } from "./ipc-rpc/menu" + export { StorageRpcs } from "./ipc-rpc/storage" + export { WindowRpcs } from "./ipc-rpc/window" export const DesktopRpcs = AppRpcs.merge(StorageRpcs, FileRpcs, WindowRpcs, MenuRpcs, EventRpcs, ExtensionRpcs) + export type DesktopRpcClient = RpcClient.FromGroup diff --git a/packages/desktop/src/shared/ipc-rpc/app.ts b/packages/desktop/src/shared/ipc-rpc/app.ts index f964e8dc1982..30496fb13a4b 100644 --- a/packages/desktop/src/shared/ipc-rpc/app.ts +++ b/packages/desktop/src/shared/ipc-rpc/app.ts @@ -6,38 +6,50 @@ const ServerReadyData = Schema.Struct({ }) export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData }) + export const AppReconnectService = Rpc.make("AppReconnectService", { success: ServerReadyData }) + export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", { success: Schema.Array(Schema.String), }) + export const AppGetDefaultServerUrl = Rpc.make("AppGetDefaultServerUrl", { success: Schema.NullOr(Schema.String), }) + export const AppSetDefaultServerUrl = Rpc.make("AppSetDefaultServerUrl", { payload: { url: Schema.NullOr(Schema.String) }, }) + export const AppIsFirstLaunchOnboardingPending = Rpc.make("AppIsFirstLaunchOnboardingPending", { success: Schema.Boolean, }) + export const AppFinishFirstLaunchOnboarding = Rpc.make("AppFinishFirstLaunchOnboarding", { payload: { createDefaultProject: Schema.Boolean }, success: Schema.NullOr(Schema.String), }) + export const AppCheckAppExists = Rpc.make("AppCheckAppExists", { payload: { appName: Schema.String }, success: Schema.Boolean, }) + export const AppResolveAppPath = Rpc.make("AppResolveAppPath", { payload: { appName: Schema.String }, success: Schema.NullOr(Schema.String), }) + export const AppSetBackgroundColor = Rpc.make("AppSetBackgroundColor", { payload: { color: Schema.String }, }) + export const AppExportDebugLogs = Rpc.make("AppExportDebugLogs", { success: Schema.String }) + export const AppSetForceFocus = Rpc.make("AppSetForceFocus", { payload: { enabled: Schema.Boolean }, }) + export const AppRecordFatalRendererError = Rpc.make("AppRecordFatalRendererError", { payload: { error: Schema.Struct({ @@ -49,10 +61,13 @@ export const AppRecordFatalRendererError = Rpc.make("AppRecordFatalRendererError }), }, }) + export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", { payload: { value: Schema.Unknown }, }) + export const AppRelaunch = Rpc.make("AppRelaunch") + export const AppRpcs = RpcGroup.make( AppAwaitInitialization, AppReconnectService, diff --git a/packages/desktop/src/shared/ipc-rpc/files.ts b/packages/desktop/src/shared/ipc-rpc/files.ts index ca68ba5db5a0..51e826cff238 100644 --- a/packages/desktop/src/shared/ipc-rpc/files.ts +++ b/packages/desktop/src/shared/ipc-rpc/files.ts @@ -3,64 +3,80 @@ import { Rpc, RpcGroup } from "effect/unstable/rpc" import { Transferable } from "effect/unstable/workers" const OptionalString = Schema.optional(Schema.String) + const PickerOptions = Schema.Struct({ multiple: Schema.optional(Schema.Boolean), title: OptionalString, defaultPath: OptionalString, }) + const FilePickerOptions = Schema.Struct({ multiple: Schema.optional(Schema.Boolean), title: OptionalString, defaultPath: OptionalString, extensions: Schema.optional(Schema.Array(Schema.String)), }) + const SavePickerOptions = Schema.Struct({ title: OptionalString, defaultPath: OptionalString }) + const PickedFiles = Schema.Struct({ token: Schema.String, files: Schema.Array(Schema.Struct({ path: Schema.String, name: Schema.String, size: Schema.Number })), }) + const ClipboardImage = Schema.Struct({ buffer: Transferable.Uint8Array, width: Schema.Number, height: Schema.Number }) export const FilesOpenDirectoryPicker = Rpc.make("FilesOpenDirectoryPicker", { payload: { options: Schema.optional(PickerOptions) }, success: Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])), }) + export const FilesOpenFilePicker = Rpc.make("FilesOpenFilePicker", { payload: { options: Schema.optional(FilePickerOptions) }, success: Schema.NullOr(PickedFiles), }) + export const FilesReadPickedFile = Rpc.make("FilesReadPickedFile", { payload: { token: Schema.String, path: Schema.String }, success: Transferable.Uint8Array, }) + export const FilesReleasePickedFiles = Rpc.make("FilesReleasePickedFiles", { payload: { token: Schema.String }, }) + export const FilesSaveFile = Rpc.make("FilesSaveFile", { payload: { options: SavePickerOptions, content: Schema.String }, success: Schema.Boolean, }) + export const FilesOpenExternal = Rpc.make("FilesOpenExternal", { payload: { url: Schema.String }, }) + export const FilesOpenBrowser = Rpc.make("FilesOpenBrowser", { payload: { url: Schema.String }, success: Schema.Boolean, }) + export const FilesOpenLocalFile = Rpc.make("FilesOpenLocalFile", { payload: { url: Schema.String }, }) + export const FilesOpenPath = Rpc.make("FilesOpenPath", { payload: { path: Schema.String, application: Schema.optional(Schema.String) }, success: Schema.NullOr(Schema.String), }) + export const FilesRevealPath = Rpc.make("FilesRevealPath", { payload: { path: Schema.String }, success: Schema.Boolean, }) + export const FilesReadClipboardImage = Rpc.make("FilesReadClipboardImage", { success: Schema.NullOr(ClipboardImage), }) + export const FilesWriteClipboardText = Rpc.make("FilesWriteClipboardText", { payload: { text: Schema.String }, }) diff --git a/packages/desktop/src/shared/ipc-rpc/menu.ts b/packages/desktop/src/shared/ipc-rpc/menu.ts index def7a5b308be..a83abbaef18d 100644 --- a/packages/desktop/src/shared/ipc-rpc/menu.ts +++ b/packages/desktop/src/shared/ipc-rpc/menu.ts @@ -26,4 +26,5 @@ const DesktopMenuAction = Schema.Literals([ export const MenuRunAction = Rpc.make("MenuRunAction", { payload: { action: DesktopMenuAction }, }) + export const MenuRpcs = RpcGroup.make(MenuRunAction) diff --git a/packages/desktop/src/shared/ipc-rpc/storage.ts b/packages/desktop/src/shared/ipc-rpc/storage.ts index 2b920b3986ba..b3438f8d93da 100644 --- a/packages/desktop/src/shared/ipc-rpc/storage.ts +++ b/packages/desktop/src/shared/ipc-rpc/storage.ts @@ -6,6 +6,7 @@ export const StorageItems = Rpc.make("StorageItems", { payload: { name: Schema.String }, success: Schema.Struct({ items: Schema.Record(Schema.String, Schema.String), revision: Schema.Number }), }) + export const StorageUpdate = Rpc.make("StorageUpdate", { payload: { name: Schema.String, @@ -14,20 +15,26 @@ export const StorageUpdate = Rpc.make("StorageUpdate", { }, success: Schema.Number, }) + export const StorageClear = Rpc.make("StorageClear", { payload: { name: Schema.String } }) + export const DraftsGet = Rpc.make("DraftsGet", { payload: { key: Schema.String }, success: Schema.NullOr(Schema.String), }) + export const DraftsSet = Rpc.make("DraftsSet", { payload: { key: Schema.String, value: Schema.String, strict: Schema.Boolean }, success: Schema.Array(Schema.String), }) + export const DraftsDelete = Rpc.make("DraftsDelete", { payload: { key: Schema.String } }) + export const DraftsPutBlob = Rpc.make("DraftsPutBlob", { payload: { data: Transferable.Uint8Array }, success: Schema.String, }) + export const DraftsGetBlob = Rpc.make("DraftsGetBlob", { payload: { id: Schema.String }, success: Schema.NullOr(Transferable.Uint8Array), diff --git a/packages/desktop/src/shared/ipc-rpc/window.ts b/packages/desktop/src/shared/ipc-rpc/window.ts index bb659b61af79..e8aca73d1a45 100644 --- a/packages/desktop/src/shared/ipc-rpc/window.ts +++ b/packages/desktop/src/shared/ipc-rpc/window.ts @@ -2,20 +2,29 @@ import { Schema } from "effect" import { Rpc, RpcGroup } from "effect/unstable/rpc" export const WindowThemeReady = Rpc.make("WindowThemeReady") + export const WindowGetFocused = Rpc.make("WindowGetFocused", { success: Schema.Boolean }) + export const WindowGetFullscreen = Rpc.make("WindowGetFullscreen", { success: Schema.Boolean }) + export const WindowSetFocus = Rpc.make("WindowSetFocus") + export const WindowShow = Rpc.make("WindowShow") + export const WindowGetZoomFactor = Rpc.make("WindowGetZoomFactor", { success: Schema.Number }) + export const WindowSetZoomFactor = Rpc.make("WindowSetZoomFactor", { payload: { factor: Schema.Number }, }) + export const WindowGetPinchZoomEnabled = Rpc.make("WindowGetPinchZoomEnabled", { success: Schema.Boolean, }) + export const WindowSetPinchZoomEnabled = Rpc.make("WindowSetPinchZoomEnabled", { payload: { enabled: Schema.Boolean }, }) + export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", { payload: { theme: Schema.Struct({ @@ -24,6 +33,7 @@ export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", { }), }, }) + export const WindowRpcs = RpcGroup.make( WindowThemeReady, WindowGetFocused, diff --git a/packages/gui-extensions/src/browser/network.ts b/packages/gui-extensions/src/browser/network.ts index 4969085d1dec..35a24835b5e3 100644 --- a/packages/gui-extensions/src/browser/network.ts +++ b/packages/gui-extensions/src/browser/network.ts @@ -14,6 +14,7 @@ export const createBrowserNetwork = Effect.fn("BrowserNetwork.create")(function* partition: string }) { const options = { location: input.location } + const proxy = yield* Effect.acquireRelease( Effect.tryPromise(() => BrowserProxy.make({ @@ -37,6 +38,7 @@ export const createBrowserNetwork = Effect.fn("BrowserNetwork.create")(function* ), (proxy) => Effect.promise(() => proxy.close()), ) + const partition = session.fromPartition(input.partition) yield* Effect.addFinalizer(() => Effect.promise(() => partition.closeAllConnections())) // This is the browser's private partition, not the app/API connection. Never @@ -45,6 +47,7 @@ export const createBrowserNetwork = Effect.fn("BrowserNetwork.create")(function* partition.setProxy({ mode: "fixed_servers", proxyRules: proxy.url, proxyBypassRules: "<-loopback>" }), ) yield* Effect.tryPromise(() => partition.closeAllConnections()) + return { attach(contents: WebContents) { const login = ( @@ -64,8 +67,10 @@ export const createBrowserNetwork = Effect.fn("BrowserNetwork.create")(function* event.preventDefault() callback(proxy.credentials.username, proxy.credentials.password) } + contents.on("login", login) contents.setWebRTCIPHandlingPolicy("disable_non_proxied_udp") + return () => contents.off("login", login) }, } diff --git a/packages/gui-extensions/src/ssh/askpass.test.ts b/packages/gui-extensions/src/ssh/askpass.test.ts index 57495b9f2997..312803385be3 100644 --- a/packages/gui-extensions/src/ssh/askpass.test.ts +++ b/packages/gui-extensions/src/ssh/askpass.test.ts @@ -25,6 +25,7 @@ const request = Effect.fn("test.askpass.request")(function* ( ], { concurrency: "unbounded" }, ) + return result.text }, Effect.scoped) @@ -32,11 +33,13 @@ it.live( "per-prompt replies are isolated, including confirmation and OTP", Effect.gen(function* () { const prompts = yield* Queue.unbounded<{ id: string; text: string; confirm: boolean }>() + const bridge = yield* createAskpass({ binary: "unused", prompt: (prompt) => Queue.offer(prompts, prompt).pipe(Effect.asVoid), clear: () => Effect.void, }) + const password = yield* request(bridge.env, "Password:").pipe(Effect.forkScoped) const first = yield* Queue.take(prompts) expect(first.text).toBe("Password:") @@ -56,11 +59,13 @@ it.live( const parent = yield* Scope.Scope const scope = yield* Scope.fork(parent) const prompted = yield* Deferred.make() + const bridge = yield* createAskpass({ binary: "unused", prompt: () => Deferred.succeed(prompted, undefined).pipe(Effect.asVoid), clear: () => Effect.void, }).pipe(Scope.provide(scope)) + expect(yield* request({ ...bridge.env, OPENCODE_SSH_ASKPASS_TOKEN: "incorrect" }, "Password:")).toBe("") const reply = yield* request(bridge.env, "Trust fingerprint?", true).pipe(Effect.forkScoped) yield* Deferred.await(prompted) diff --git a/packages/gui-extensions/src/ssh/askpass.ts b/packages/gui-extensions/src/ssh/askpass.ts index bff73df673ab..6be836909119 100644 --- a/packages/gui-extensions/src/ssh/askpass.ts +++ b/packages/gui-extensions/src/ssh/askpass.ts @@ -1,5 +1,5 @@ import { NodeSocketServer } from "@effect/platform-node" -import { Deferred, Effect, Fiber, Schema, Semaphore } from "effect" +import { Deferred, Effect, Fiber, Predicate, Schema, Semaphore } from "effect" import { randomUUID } from "node:crypto" import { SshFailure } from "./command" @@ -16,24 +16,31 @@ export const createAskpass = Effect.fn("Ssh.askpass")(function* (input: { const pending = new Map>() const prompts = yield* Semaphore.make(1) const server = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 }).pipe(Effect.mapError(SshFailure.from)) - if (server.address._tag !== "TcpAddress") return yield* Effect.fail(new SshFailure("connection")) + + if (!Predicate.isTagged(server.address, "TcpAddress")) return yield* Effect.fail(new SshFailure("connection")) const serving = yield* server .run((socket) => Effect.gen(function* () { const request = yield* Deferred.make() const state = { buffer: "", received: false } + const reader = yield* socket .runString((chunk) => { if (state.received) return Effect.fail(new SshFailure("connection")) state.buffer += chunk + if (state.buffer.length > 16_384) return Effect.fail(new SshFailure("connection")) + if (!state.buffer.includes("\n")) return Effect.void state.received = true + return Deferred.succeed(request, state.buffer.trim()) }) .pipe(Effect.ensuring(Deferred.fail(request, new SshFailure("connection"))), Effect.forkScoped) + const message = yield* Deferred.await(request).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Request))) + if (message.token !== token) return // One scoped waiter per helper invocation. Disconnecting a helper or closing @@ -75,6 +82,7 @@ export const createAskpass = Effect.fn("Ssh.askpass")(function* (input: { closed: Fiber.join(serving), respond: Effect.fn("Ssh.askpass.respond")(function* (id: string, value: string) { const response = pending.get(id) + if (response) yield* Deferred.succeed(response, value) }), } diff --git a/packages/gui-extensions/src/ssh/bootstrap.ts b/packages/gui-extensions/src/ssh/bootstrap.ts index daf1258167b9..899d0feaf53b 100644 --- a/packages/gui-extensions/src/ssh/bootstrap.ts +++ b/packages/gui-extensions/src/ssh/bootstrap.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import { HttpClient } from "effect/unstable/http" import { parseTarget, quote, runSsh, sshArgs, SshFailure } from "./command" import { RemoteCli } from "./remote-cli" @@ -42,13 +42,17 @@ export function parseRegistration(output: string) { .split(/\r?\n/) .findLast((line) => line.startsWith("OPENCODE_SSH_STATUS=")) ?.slice("OPENCODE_SSH_STATUS=".length) + if (!status) return undefined + for (const match of output.matchAll( /OPENCODE_SSH_REGISTRATION_BEGIN\r?\n([\s\S]*?)\r?\nOPENCODE_SSH_REGISTRATION_END/g, )) { const result = Schema.decodeUnknownOption(Registration)(match[1]) - if (result._tag === "Some" && result.value.url === status) return result.value + + if (Option.isSome(result) && result.value.url === status) return result.value } + return undefined } @@ -58,8 +62,10 @@ export function binaryPath(version: string) { function connectionAddress(address: string, password: string) { const url = new URL(address) + if (url.protocol !== "http:" || !["127.0.0.1", "localhost", "0.0.0.0", "[::]", "[::1]"].includes(url.hostname)) throw new SshFailure("service") + return { host: url.hostname === "[::1]" ? "[::1]" : "127.0.0.1", port: Number(url.port || 80), @@ -81,30 +87,39 @@ export const bootstrap = Effect.fn("Ssh.bootstrap")(function* (input: { env: input.env, stdin: script, }) + yield* input.stage("checking") const registered = parseRegistration(yield* run(discoverScript)) + if (registered && (input.development || registered.version === input.version)) { yield* input.stage("starting") + return yield* Effect.try({ try: () => connectionAddress(registered.url, registered.password), catch: SshFailure.from, }) } + if (registered && !input.replace) return yield* Effect.fail(new SshFailure("version", registered.version)) const destination = yield* Effect.try({ try: () => binaryPath(input.version), catch: SshFailure.from }) const existing = yield* run(RemoteCli.versionScript(`"${destination}"`)) const staged = RemoteCli.parseVersion(existing) === input.version + // Source worktree versions are unpublished. Use the installer's beta channel // while retaining support for explicitly staged, matching development builds. const version = input.development && !staged ? yield* RemoteCli.latestBeta().pipe(Effect.mapError(SshFailure.from)) : input.version + const setup = { version, directory: `.opencode/desktop-ssh/${version}` } + if (!staged) { const output = yield* run(RemoteCli.probeScript).pipe(Effect.mapError(() => new SshFailure("platform"))) + const target = output .split(/\r?\n/) .findLast((line) => line.startsWith("OPENCODE_REMOTE_TARGET=")) ?.split("=")[1] + const url = yield* Effect.try({ try: () => RemoteCli.archiveUrl(target ?? "", version), catch: SshFailure.from }) yield* input.stage("downloading") yield* run(RemoteCli.installScript({ ...setup, source: { type: "download", url } })).pipe( @@ -113,6 +128,7 @@ export const bootstrap = Effect.fn("Ssh.bootstrap")(function* (input: { yield* input.stage("uploading") const http = yield* HttpClient.HttpClient const response = yield* http.get(url).pipe(Effect.mapError(SshFailure.from)) + if (response.status < 200 || response.status >= 300) return yield* Effect.fail( new SshFailure( @@ -121,6 +137,7 @@ export const bootstrap = Effect.fn("Ssh.bootstrap")(function* (input: { ), ) const archive = new Uint8Array(yield* response.arrayBuffer.pipe(Effect.mapError(SshFailure.from))) + // The upload uses stdin; the script itself must be the remote command. return yield* runSsh({ args: [ @@ -135,11 +152,15 @@ export const bootstrap = Effect.fn("Ssh.bootstrap")(function* (input: { ), ) } + yield* input.stage("starting") const registration = parseRegistration(yield* run(startScript(version, input.replace))) + if (!registration) return yield* Effect.fail(new SshFailure("service")) + if (!input.development && registration.version !== input.version) return yield* Effect.fail(new SshFailure("version", registration.version)) + return yield* Effect.try({ try: () => connectionAddress(registration.url, registration.password), catch: SshFailure.from, diff --git a/packages/gui-extensions/src/ssh/command.ts b/packages/gui-extensions/src/ssh/command.ts index 85b34cef4fcb..420ccb09b680 100644 --- a/packages/gui-extensions/src/ssh/command.ts +++ b/packages/gui-extensions/src/ssh/command.ts @@ -1,4 +1,4 @@ -import { Effect, PlatformError, Schema, Stream } from "effect" +import { Effect, PlatformError, Predicate, Schema, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { RemoteCli } from "./remote-cli" @@ -23,17 +23,19 @@ export class SshFailure extends Schema.TaggedError()("SshFailure", { return this.detail } - static from(this: void, error: unknown) { - if (error instanceof RemoteCli.Failure) return new SshFailure(error.code, error.detail) + static from(this: void, cause: unknown) { + if (cause instanceof RemoteCli.Failure) return new SshFailure(cause.code, cause.detail) + if ( - error instanceof PlatformError.PlatformError && - error.reason._tag === "NotFound" && - error.reason.method === "spawn" + cause instanceof PlatformError.PlatformError && + Predicate.isTagged(cause.reason, "NotFound") && + cause.reason.method === "spawn" ) - return new SshFailure("ssh-missing", error.message) - return error instanceof SshFailure - ? error - : new SshFailure("connection", error instanceof Error ? error.message : String(error)) + return new SshFailure("ssh-missing", cause.message) + + return cause instanceof SshFailure + ? cause + : new SshFailure("connection", cause instanceof Error ? cause.message : String(cause)) } } @@ -44,37 +46,48 @@ export function quote(value: string) { export function parseTarget(input: string) { const tokens: string[] = [] const state = { word: "", quote: "", started: false } + for (let i = 0; i < input.length; i++) { const c = input[i] ?? "" + if (c === "\n" || c === "\r" || c === "\0") throw new SshFailure("input") + if (c === "\\" && state.quote !== "'" && i + 1 < input.length && /[\s\\"']/.test(input[i + 1] ?? "")) { state.word += input[++i] state.started = true continue } + if (state.quote) { if (c === state.quote) state.quote = "" else state.word += c continue } + if (c === "'" || c === '"') { state.quote = c state.started = true continue } + if (/\s/.test(c)) { if (state.started) tokens.push(state.word) state.word = "" state.started = false continue } + state.word += c state.started = true } + if (state.quote) throw new SshFailure("input") + if (state.started) tokens.push(state.word) + if (tokens[0] === "ssh") tokens.shift() const args: string[] = [] + const options = new Set([ "hostname", "user", @@ -87,25 +100,36 @@ export function parseTarget(input: string) { "connecttimeout", "addressfamily", ]) + while (tokens[0]?.startsWith("-")) { const token = tokens.shift() ?? "" + if (["-4", "-6", "-C", "-A", "-a"].includes(token)) { args.push(token) continue } + const flag = token.slice(0, 2) + if (!["-p", "-l", "-i", "-F", "-J", "-o"].includes(flag)) throw new SshFailure("input") const value = token.length > 2 ? token.slice(2) : tokens.shift() + if (!value || value.startsWith("-")) throw new SshFailure("input") + if (flag === "-p" && (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535)) throw new SshFailure("input") + if (flag === "-o" && !options.has((value.split(/[=\s]/)[0] ?? "").toLowerCase())) throw new SshFailure("input") args.push(flag, value) } + const host = tokens[0] + if (tokens.length !== 1 || !host || !/^[a-zA-Z0-9_@.:[\]%-]+$/.test(host) || host.startsWith("-")) throw new SshFailure("input") + if (host.includes("@") && host.slice(0, host.lastIndexOf("@")).includes(":")) throw new SshFailure("input") + return { host, args } } @@ -159,6 +183,7 @@ export const runSsh = Effect.fn("Ssh.run")(function* (input: { timeout?: number }) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + return yield* Effect.gen(function* () { const child = yield* spawner.spawn( ChildProcess.make(sshExecutable(), input.args, { @@ -172,12 +197,13 @@ export const runSsh = Effect.fn("Ssh.run")(function* (input: { ? "ignore" : { stream: Stream.make( - typeof input.stdin === "string" ? new TextEncoder().encode(input.stdin) : input.stdin, + Predicate.isString(input.stdin) ? new TextEncoder().encode(input.stdin) : input.stdin, ), endOnDone: true, }, }), ) + const output = yield* Effect.all( { stdout: child.stdout.pipe( @@ -198,8 +224,10 @@ export const runSsh = Effect.fn("Ssh.run")(function* (input: { }, { concurrency: "unbounded" }, ) + if (output.code !== 0) return yield* Effect.fail(new SshFailure("connection", commandFailureDetail(output.code, output))) + return output.stdout }).pipe(Effect.scoped, Effect.timeout(input.timeout ?? 600_000), Effect.mapError(SshFailure.from)) }) @@ -210,5 +238,6 @@ export function commandFailureDetail(code: number | null, output: { stdout: stri const stdout = output.stdout .replace(/OPENCODE_SSH_REGISTRATION_BEGIN[\s\S]*?(?:OPENCODE_SSH_REGISTRATION_END|$)/g, "") .trim() + return [output.stderr.trim(), stdout].filter(Boolean).join("\n") || JSON.stringify({ exitCode: code }) } diff --git a/packages/gui-extensions/src/ssh/controller.ts b/packages/gui-extensions/src/ssh/controller.ts index 9accd2506426..0eb05b9927cf 100644 --- a/packages/gui-extensions/src/ssh/controller.ts +++ b/packages/gui-extensions/src/ssh/controller.ts @@ -8,6 +8,7 @@ import { Fiber, FileSystem, Path, + Predicate, PubSub, Ref, Schedule, @@ -27,6 +28,7 @@ type Connection = { ready: Deferred.Deferred respond?: (id: string, value: string) => Effect.Effect } + type Attempt = Connection & { fiber: Fiber.Fiber } export const createSshController = Effect.fn("Ssh.controller")(function* (input: { @@ -49,12 +51,14 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: const items = new Map( input.configs.map((config) => [config.id, { config, saved: true, stage: "disconnected", detail: "" }]), ) + const configs = new Map(input.configs.map((config) => [config.id, config])) const attempts = new Map() const paused = new Set(items.keys()) const failures = new Map() const lifecycle = { closed: false } const emit = PubSub.publish(changed, undefined).pipe(Effect.asVoid) + const state = (owner?: number): Effect.Effect> => Effect.sync(() => ({ servers: [...items.values()].map((item) => ({ @@ -66,12 +70,22 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: attempts.get(item.config.id)?.owner !== owner, })), })) + const update = Effect.fnUntraced(function* (id: string, value: Partial) { const item = items.get(id) + if (!item) return items.set(id, { ...item, ...value }) yield* emit }) + + // A saved server's item takes its current saved config along with the change. + const withSavedConfig = (id: string, value: Partial): Partial => { + const config = configs.get(id) + + return config ? { ...value, config } : value + } + const run = (options: Parameters[0]) => runSsh(options).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)) @@ -79,14 +93,17 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: const target = yield* Effect.try({ try: () => parseTarget(config.target), catch: SshFailure.from }) const directory = yield* fs.makeTempDirectoryScoped({ prefix: "oc-ssh-" }) const control = path.join(directory, "s") + const helper = input.command && input.command.length > 1 && process.platform !== "win32" ? path.join(directory, "askpass") : input.binary + if (helper !== input.binary) yield* fs.writeFileString(helper, `#!/bin/sh\nexec ${input.command?.map(quote).join(" ")} "$@"\n`, { mode: 0o700, }) + if (process.platform !== "win32") { target.args.unshift("-o", "ControlMaster=auto", "-o", "ControlPersist=60", "-o", `ControlPath=${control}`) // Close only our local SSH master. The remote OpenCode service owns its @@ -97,6 +114,7 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: } const authentication = yield* Deferred.make() + const askpass = yield* createAskpass({ binary: helper, prompt: Effect.fnUntraced(function* (prompt) { @@ -104,8 +122,10 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: paused.add(config.id) yield* update(config.id, { stage: "authentication" }) yield* Deferred.succeed(authentication, undefined) + return } + yield* update(config.id, { stage: "authentication", prompt }) }), clear: (id) => @@ -113,22 +133,27 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: ? update(config.id, { prompt: undefined, stage: "connecting" }) : Effect.void, }) + connection.respond = askpass.respond yield* Effect.gen(function* () { const resolved = yield* run({ args: [...sshArgs(target), "-G", target.host], timeout: 10_000 }).pipe( Effect.orElseSucceed(() => ""), ) + const fields = new Map( resolved.split(/\r?\n/).map((line) => { const separator = line.indexOf(" ") + return [line.slice(0, separator), line.slice(separator + 1)] as const }), ) + if (fields.has("hostname")) yield* update(config.id, { destination: `${fields.get("user") ?? ""}@${fields.get("hostname")}:${fields.get("port") ?? "22"}`, }) + const remote = yield* bootstrap({ target, version: input.version, @@ -140,8 +165,10 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(HttpClient.HttpClient, httpClient), ) + const port = yield* freePort const http = { url: `http://127.0.0.1:${port}`, password: remote.password } + const tunnel = yield* spawner.spawn( ChildProcess.make(sshExecutable(), tunnelArgs(target, port, remote), { env: askpass.env, @@ -153,19 +180,24 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: forceKillAfter: "2 seconds", }), ) + const detail = yield* Ref.make("") + const stderr = yield* tunnel.stderr.pipe( Stream.decodeText(), Stream.runForEach((text) => Ref.update(detail, (tail) => (tail + text).slice(-8192))), Effect.forkScoped, ) + const closed = Effect.gen(function* () { const exitCode = yield* tunnel.exitCode yield* Fiber.join(stderr) + return yield* Effect.fail( new SshFailure("connection", (yield* Ref.get(detail)) || JSON.stringify({ exitCode })), ) }) + yield* waitReady(http, () => items.get(config.id)?.stage === "authentication").pipe( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.catch(() => @@ -188,20 +220,26 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: const start = Effect.fn("Ssh.start")(function* (request: SshStart, owner?: number) { const id = request.id + if (lifecycle.closed || !/^[a-zA-Z0-9-]{1,80}$/.test(id)) return const previous = attempts.get(id) + // A second window must not replace an interactive attempt while its owner // is connecting or answering a challenge. if (previous?.owner !== undefined && previous.owner !== owner && items.get(id)?.stage !== "ready") return const config = { id, target: request.target.trim(), name: request.name.trim() } + const connection: Connection = { owner, before: items.get(id), ready: yield* Deferred.make(), } + const admitted = yield* Deferred.make() + const fiber = yield* Effect.gen(function* () { yield* Deferred.await(admitted) + if (previous) yield* Fiber.interrupt(previous.fiber) yield* connect(config, connection, request.replace) }).pipe( @@ -210,29 +248,34 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: if (Cause.hasInterruptsOnly(cause) || paused.has(id) || attempts.get(id)?.ready !== connection.ready) return const failure = SshFailure.from(Cause.squash(cause)) failures.set(id, (failures.get(id) ?? 0) + 1) + const code = /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed/.test(failure.message) ? "host-key" : /spawn .*ENOENT/.test(failure.message) ? "ssh-missing" : failure.code + if ( ["version", "input", "unpublished", "platform", "host-key", "ssh-missing"].includes(code) || /Permission denied/.test(failure.message) || (failures.get(id) ?? 0) >= 5 ) paused.add(id) - yield* update(id, { - stage: code === "version" ? "incompatible" : "failed", - error: code, - detail: failure.message, - prompt: undefined, - ...(configs.has(id) ? { config: configs.get(id) } : {}), - }) + yield* update( + id, + withSavedConfig(id, { + stage: code === "version" ? "incompatible" : "failed", + error: code, + detail: failure.message, + prompt: undefined, + }), + ) }), ), Effect.ensuring( Effect.gen(function* () { yield* Deferred.succeed(connection.ready, null) + if (attempts.get(id)?.ready !== connection.ready) return attempts.delete(id) yield* update(id, { prompt: undefined }) @@ -240,6 +283,7 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: ), Effect.forkIn(lifetime, { uninterruptible: false }), ) + attempts.set(id, Object.assign(connection, { fiber })) items.set(id, { config, @@ -249,6 +293,7 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: detail: "", }) paused.delete(id) + if (!request.background) failures.delete(id) yield* emit yield* Deferred.succeed(admitted, undefined) @@ -257,15 +302,14 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: const disconnect = Effect.fn("Ssh.disconnect")(function* (id: string) { paused.add(id) const attempt = attempts.get(id) - yield* update(id, { - stage: "disconnected", - prompt: undefined, - ...(configs.has(id) ? { config: configs.get(id) } : {}), - }) + yield* update(id, withSavedConfig(id, { stage: "disconnected", prompt: undefined })) + if (attempt) yield* Fiber.interrupt(attempt.fiber) }) + const cancel = Effect.fn("Ssh.cancel")(function* (id: string, owner: number) { const attempt = attempts.get(id) + if (!attempt || attempt.owner !== owner) return paused.add(id) // Restore before interrupting: askpass cleanup must not transition the @@ -283,12 +327,14 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: }) yield* Fiber.interrupt(attempt.fiber) }) + const close = Effect.gen(function* () { if (lifecycle.closed) return lifecycle.closed = true yield* Effect.forEach([...attempts.keys()], disconnect, { concurrency: "unbounded", discard: true }) yield* Scope.close(lifetime, Exit.void) }) + yield* Effect.addFinalizer(() => close) return { @@ -297,22 +343,29 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: start, resolve: Effect.fn("Ssh.resolve")(function* (id: string) { const item = items.get(id) + if (lifecycle.closed || !item || paused.has(id)) return null + if (item.stage === "ready" && item.http) { const healthy = yield* checkHealth(item.http).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)) + if (lifecycle.closed || paused.has(id)) return null + // Another window may already have replaced this tunnel during the probe. if (items.get(id) === item) { if (healthy) return item.http yield* start({ ...item.config, background: true }) } } + if (!attempts.has(id) && items.has(id)) yield* start({ ...item.config, background: true }) const attempt = attempts.get(id) + return attempt ? yield* Deferred.await(attempt.ready) : null }), respond: Effect.fn("Ssh.respond")(function* (id: string, prompt: string, value: string, owner: number) { const attempt = attempts.get(id) + if (attempt?.owner === owner && attempt.respond) yield* attempt.respond(prompt, value) }), disconnect, @@ -337,7 +390,9 @@ export const createSshController = Effect.fn("Ssh.controller")(function* (input: const freePort = Effect.gen(function* () { const server = yield* NodeSocketServer.make({ host: "127.0.0.1", port: 0 }) - if (server.address._tag !== "TcpAddress") return yield* Effect.fail(new SshFailure("connection")) + + if (!Predicate.isTagged(server.address, "TcpAddress")) return yield* Effect.fail(new SshFailure("connection")) + return server.address.port }).pipe(Effect.scoped) @@ -345,14 +400,18 @@ const waitReady = Effect.fn("Ssh.waitReady")(function* (http: SshHttp, authentic const clock = { deadline: (yield* Clock.currentTimeMillis) + 30_000 } yield* Effect.gen(function* () { const now = yield* Clock.currentTimeMillis + if (authenticating()) clock.deadline = now + 30_000 + if (now >= clock.deadline) return yield* Effect.fail(new SshFailure("service")) + return yield* checkHealth(http) }).pipe(Effect.repeat({ until: (ready) => ready, schedule: Schedule.spaced(100) })) }) const checkHealth = Effect.fn("Ssh.checkHealth")(function* (http: SshHttp) { const client = yield* HttpClient.HttpClient + return yield* client .get(`${http.url}/api/info`, { headers: { authorization: `Basic ${Buffer.from(`opencode:${http.password}`).toString("base64")}` }, diff --git a/packages/gui-extensions/src/ssh/remote-cli.ts b/packages/gui-extensions/src/ssh/remote-cli.ts index e516f558fad2..a59326684725 100644 --- a/packages/gui-extensions/src/ssh/remote-cli.ts +++ b/packages/gui-extensions/src/ssh/remote-cli.ts @@ -19,6 +19,7 @@ export function quote(value: string) { export function requireVersion(version: string) { if (version !== "local" && !/^[0-9][a-zA-Z0-9.+-]*$/.test(version)) throw new Failure({ code: "version", detail: version }) + return version } @@ -48,10 +49,13 @@ export function parseVersion(output: string) { .split(/\r?\n/) .find((line) => line.trim()) ?.trim() + if (!line) return null const marker = line.lastIndexOf(" v") const version = marker === -1 ? line : line.slice(marker + 2) + if (!version) throw new Error("V2 CLI did not provide a version") + return version } @@ -71,6 +75,7 @@ printf 'OPENCODE_REMOTE_TARGET=%s\\n' "$target" export function archiveUrl(target: string, version: string) { if (!/^(linux|darwin)-(x64-baseline|arm64)(-musl)?$/.test(target)) throw new Failure({ code: "platform", detail: target }) + return `https://registry.npmjs.org/@opencode/cli-${target}/-/cli-${target}-${requireVersion(version)}.tgz` } @@ -78,6 +83,7 @@ type Source = { type: "download"; url: string } | { type: "archive" } | { type: export function installScript(input: { version: string; directory?: string; source: Source }) { const version = requireVersion(input.version) + // The managed CLI installer also configures the user's shell PATH. Private // installations use archives so their destination and shell setup stay isolated. if (input.source.type === "installer") @@ -85,6 +91,7 @@ export function installScript(input: { version: string; directory?: string; sour curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s -- ${input.source.binary ? `--binary ${input.source.binary}` : `--version ${quote(version)}`} ${verifyScript('"$HOME/.opencode/bin/opencode"', version)} ` + return `set -eu umask 077 destination="$HOME"/${quote(`${input.directory ?? ".opencode/bin"}/opencode`)} @@ -100,6 +107,7 @@ mv "$stage/package/bin/opencode" "$destination" function stageBinary(source: Exclude) { if (source.type === "archive") return 'cat > "$stage/archive.tgz"\ntar -xzf "$stage/archive.tgz" -C "$stage"' + return `url=${quote(source.url)} if command -v curl >/dev/null 2>&1; then curl -fsSL --connect-timeout 15 --max-time 180 "$url" -o "$stage/archive.tgz" @@ -117,11 +125,13 @@ const Beta = Schema.Struct({ version: Schema.String.check(Schema.isPattern(/^0\. export const latestBeta = Effect.fn("RemoteCli.latestBeta")(function* () { const http = yield* HttpClient.HttpClient + const metadata = yield* http.get("https://registry.npmjs.org/@opencode%2fcli/beta").pipe( Effect.flatMap(HttpClientResponse.filterStatusOk), Effect.flatMap(HttpClientResponse.schemaBodyJson(Beta)), Effect.timeout("30 seconds"), Effect.mapError(() => new Failure({ code: "install", detail: "https://registry.npmjs.org/@opencode%2fcli/beta" })), ) + return metadata.version })