mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-11 08:48:45 +03:00
chore(app): clear lint warnings in GUI files touched by the Effect upgrade (#53098)
This commit is contained in:
committed by
GitHub
Unverified
parent
0a46301e36
commit
eaf80d99e5
@@ -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<string, State>()
|
||||
|
||||
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<State | undefined>(() => {
|
||||
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<string, State>()
|
||||
|
||||
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
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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" }])
|
||||
|
||||
@@ -23,10 +23,13 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
initial: NoInfer<S["Type"]>,
|
||||
) {
|
||||
const schema = isMigrated(definition) ? definition.current : definition
|
||||
|
||||
const read = isMigrated(definition)
|
||||
? SchemaParser.decodeUnknownResult(definition.read, { onExcessProperty: "preserve" })
|
||||
: Result.succeed<unknown>
|
||||
|
||||
const encode = Schema.encodeUnknownSync(schema)
|
||||
|
||||
return Schema.Unknown.pipe(
|
||||
Schema.decode<Schema.Unknown>({
|
||||
decode: SchemaGetter.transformOrFail((value) =>
|
||||
@@ -39,25 +42,37 @@ export function withInitial<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
}
|
||||
|
||||
// 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<Schema.Codec<unknown, unknown>>(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<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S, value: () => S["Type"]) {
|
||||
const defaulted = Schema.withDecodingDefaultType<S>(Effect.sync(value))(schema)
|
||||
|
||||
return Schema.catchDecoding<typeof defaulted>(() => Effect.sync(() => Option.some(value())))(defaulted)
|
||||
}
|
||||
|
||||
export function optional<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
|
||||
const field = Schema.optional(schema)
|
||||
|
||||
return Schema.catchDecoding<typeof field>(() => Effect.succeed(Option.none()))(field)
|
||||
}
|
||||
|
||||
@@ -80,6 +97,7 @@ export function struct<const Fields extends Schema.Struct.Fields>(fields: Fields
|
||||
|
||||
export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schema: S) {
|
||||
const entries = Schema.Record(Schema.String, Schema.mutableKey(schema))
|
||||
|
||||
return fallback(entries, () => Schema.decodeUnknownSync(entries)({}))
|
||||
}
|
||||
|
||||
@@ -87,6 +105,7 @@ export function record<S extends Schema.ConstraintCodec<unknown, unknown>>(schem
|
||||
export function array<S extends Schema.ConstraintCodec<unknown, unknown>>(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))), {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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>()("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<A>(value: A) {
|
||||
return { _tag: "Success", value } as const
|
||||
}
|
||||
|
||||
function died<A>(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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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<keyof DesktopRpcClient, "DesktopEvents">
|
||||
|
||||
type InvokeArgs<Tag extends InvokeTag> = Parameters<DesktopRpcClient[Tag]>
|
||||
|
||||
type InvokeResult<Tag extends InvokeTag> =
|
||||
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
|
||||
|
||||
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
|
||||
|
||||
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<unknown>) => void
|
||||
}
|
||||
|
||||
const pending = new Map<number, Pending>()
|
||||
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
|
||||
|
||||
const listeners = new Map<EventTag, Set<(value: DesktopEvent) => 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<unknown> | void>()
|
||||
|
||||
let nextId = 0
|
||||
|
||||
const port = new Promise<MessagePort>((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<DesktopEvent>) 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<unknown> | void) {
|
||||
beforeDispose.add(callback)
|
||||
|
||||
return () => beforeDispose.delete(callback)
|
||||
}
|
||||
|
||||
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
|
||||
// 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<InvokeResult<Tag>>
|
||||
}
|
||||
|
||||
@@ -71,37 +90,55 @@ export function cancellable<Tag extends InvokeTag>(
|
||||
payload: InvokeArgs<Tag>[0],
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<InvokeResult<Tag>> {
|
||||
// SAFETY: as in `invoke`, the reply is the success value of `tag`'s RPC.
|
||||
return request(tag, payload ?? null, undefined, signal) as Promise<InvokeResult<Tag>>
|
||||
}
|
||||
|
||||
export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventValue<Tag>) => 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<InvokeTag>[0] | null,
|
||||
chunk?: Pending["chunk"],
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const id = nextId++
|
||||
|
||||
return new Promise<unknown>((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 =
|
||||
<Value>(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 */
|
||||
|
||||
@@ -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<typeof DesktopRpcs, RpcClientError.RpcClientError>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -26,4 +26,5 @@ const DesktopMenuAction = Schema.Literals([
|
||||
export const MenuRunAction = Rpc.make("MenuRunAction", {
|
||||
payload: { action: DesktopMenuAction },
|
||||
})
|
||||
|
||||
export const MenuRpcs = RpcGroup.make(MenuRunAction)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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<void>()
|
||||
|
||||
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)
|
||||
|
||||
@@ -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<string, Deferred.Deferred<string>>()
|
||||
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<string, SshFailure>()
|
||||
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)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>()("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 })
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Fiber,
|
||||
FileSystem,
|
||||
Path,
|
||||
Predicate,
|
||||
PubSub,
|
||||
Ref,
|
||||
Schedule,
|
||||
@@ -27,6 +28,7 @@ type Connection = {
|
||||
ready: Deferred.Deferred<SshHttp | null>
|
||||
respond?: (id: string, value: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
type Attempt = Connection & { fiber: Fiber.Fiber<void> }
|
||||
|
||||
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<string, SshItem>(
|
||||
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<string, Attempt>()
|
||||
const paused = new Set(items.keys())
|
||||
const failures = new Map<string, number>()
|
||||
const lifecycle = { closed: false }
|
||||
const emit = PubSub.publish(changed, undefined).pipe(Effect.asVoid)
|
||||
|
||||
const state = (owner?: number): Effect.Effect<Pick<SshState, "servers">> =>
|
||||
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<SshItem>) {
|
||||
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<SshItem>): Partial<SshItem> => {
|
||||
const config = configs.get(id)
|
||||
|
||||
return config ? { ...value, config } : value
|
||||
}
|
||||
|
||||
const run = (options: Parameters<typeof runSsh>[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<void>()
|
||||
|
||||
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<SshHttp | null>(),
|
||||
}
|
||||
|
||||
const admitted = yield* Deferred.make<void>()
|
||||
|
||||
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")}` },
|
||||
|
||||
@@ -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<Source, { type: "installer" }>) {
|
||||
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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user