mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 15:17:51 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c92541a40d | ||
|
|
ee7317fbcf | ||
|
|
2f8f4975f3 | ||
|
|
c91b330887 | ||
|
|
7b01b0224a |
@@ -93,7 +93,6 @@
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"uqr": "0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
|
||||
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
|
||||
const password = "pairing-secret"
|
||||
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
const requests: { origin: string; authorization: string | undefined }[] = []
|
||||
await page.addInitScript((origin) => {
|
||||
if (localStorage.getItem("opencode.global.dat:server")) return
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
|
||||
)
|
||||
}, origin)
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push({
|
||||
origin: new URL(route.request().url()).origin,
|
||||
authorization: route.request().headers().authorization,
|
||||
})
|
||||
// Pairing must succeed even when the API is unavailable.
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
|
||||
})
|
||||
|
||||
await page.goto(`/connect?data=${encodeURIComponent(JSON.stringify({ username: "opencode", password }))}`)
|
||||
await expect(page).toHaveURL(`${origin}/`)
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
|
||||
.toEqual([{ type: "http", http: { url: origin, password } }])
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
|
||||
requests.length = 0
|
||||
await page.reload()
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("the hosted app hands HTTP-only pairing links over to the server's own web UI", async ({ page, baseURL }) => {
|
||||
const dev = new URL(baseURL ?? "http://127.0.0.1:3000").origin
|
||||
const hosted = "https://app.opencode.ai"
|
||||
const lan = "http://192.168.1.20:49374"
|
||||
// Serve the dev build under the hosted HTTPS origin so mixed-content rules apply to the page.
|
||||
await page.route(`${hosted}/**`, async (route) => {
|
||||
const response = await page.request.fetch(route.request().url().replace(hosted, dev))
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
await page.route(`${lan}/**`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "text/html", body: "<title>served</title>" }),
|
||||
)
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
const info = { urls: [lan], username: "opencode", password: "lan-secret" }
|
||||
const fragment = Buffer.from(JSON.stringify(info)).toString("base64url")
|
||||
|
||||
await page.goto(`${hosted}/connect#${fragment}`)
|
||||
await expect(page.getByRole("heading", { name: "This server is on a local network" })).toBeVisible()
|
||||
await expect(page.getByText(lan, { exact: true })).toBeVisible()
|
||||
expect(requests).toEqual([])
|
||||
|
||||
await page.getByRole("button", { name: "Open on local network" }).click()
|
||||
await expect(page).toHaveURL(`${lan}/connect#${fragment}`)
|
||||
})
|
||||
|
||||
test("the unpaired page loads without starting server requests", async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
await page.goto("/connect")
|
||||
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
|
||||
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
@@ -93,7 +93,6 @@
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"uqr": "0.1.3"
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
+16
-24
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
|
||||
import { Font } from "@opencode/ui/font"
|
||||
import { ThemeProvider } from "@opencode/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/shell/commands/command"
|
||||
import { DesktopCommands } from "@/shell/commands/desktop"
|
||||
@@ -107,29 +107,21 @@ export function AppInterface(props: {
|
||||
// The visual layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
// providers beneath it.
|
||||
const Root = (rootProps: ParentProps) => {
|
||||
const location = useLocation()
|
||||
// Pairing saves credentials before mounting any server connections or health checks.
|
||||
return (
|
||||
<>
|
||||
const Root = (rootProps: ParentProps) => (
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<BodyTypography />
|
||||
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
)
|
||||
|
||||
return (
|
||||
<ServersProvider
|
||||
|
||||
@@ -52,14 +52,14 @@ export function createComposerAttachments(
|
||||
// Uploads this composer started; they finish (or fail) even if the composer unmounts.
|
||||
const [pending, setPending] = createStore<{ ids: string[] }>({ ids: [] })
|
||||
|
||||
// Media the model reads natively travels inline with the prompt, so its bytes live in the draft
|
||||
// store. Everything else, including text, reaches the model as a path on the server that its
|
||||
// tools open; those bytes never enter the store, and never get base64-encoded into the request.
|
||||
// A file the model reads natively travels inline with the prompt, so its bytes live in the draft
|
||||
// store. Anything else reaches the model as a path on the server and never enters the store:
|
||||
// hashing and copying a large archive through it is what used to freeze the window.
|
||||
const add = async (file: File, target = capture(), clipboard = false) => {
|
||||
if (!target) return false
|
||||
const mime = await attachmentMime(file)
|
||||
const destination = input.destination()
|
||||
if (native(mime, destination.input) && file.size <= MAX_INLINE_BYTES) return addInline(file, mime, target, clipboard)
|
||||
if (native(mime, destination.input)) return addInline(file, mime, target, clipboard)
|
||||
const sourcePath = input.getPathForFile?.(file) || undefined
|
||||
if (destination.local && sourcePath) return addPath(target, { filename: file.name, mime, path: sourcePath })
|
||||
void stage(file, mime, target, destination)
|
||||
@@ -213,11 +213,9 @@ export function createComposerAttachments(
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
|
||||
// The server rejects inline attachments above this size, so larger media takes the path route.
|
||||
const MAX_INLINE_BYTES = 20 * 1024 * 1024
|
||||
|
||||
// Mirrors the media the server forwards to the model as message content.
|
||||
// Mirrors the attachment kinds the server forwards to the model as message content.
|
||||
function native(mime: string, input: AttachmentDestination["input"]) {
|
||||
if (mime === "text/plain") return true
|
||||
if (imageMimes.has(mime)) return input.image
|
||||
if (mime === "application/pdf") return input.pdf
|
||||
return false
|
||||
@@ -240,8 +238,8 @@ const textMimes = new Set([
|
||||
"application/yaml",
|
||||
])
|
||||
|
||||
// Text-like files normalize to text/plain so the chip labels them as text; every other file keeps
|
||||
// a binary type. Delivery is decided separately: native media inline, everything else by path.
|
||||
// Text-like files normalize to text/plain so the server inlines their content; every other
|
||||
// file keeps a binary type and is delivered to the model by path or as native media.
|
||||
async function attachmentMime(file: File) {
|
||||
const type = file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
|
||||
if (imageMimes.has(type) || type === "application/pdf") return type
|
||||
|
||||
@@ -440,44 +440,6 @@ export const dict = {
|
||||
"No camera is available to this browser. Enter your connection details manually.",
|
||||
"server.connect.camera.error":
|
||||
"Could not open the camera. Allow camera access or enter your connection details manually.",
|
||||
"server.connect.local.title": "This server is on a local network",
|
||||
"server.connect.local.description":
|
||||
"This page can't reach HTTP servers on your local network. Open the server's own web interface to continue on this device.",
|
||||
"server.connect.local.open": "Open on local network",
|
||||
"server.connect.local.loopback.title": "This server only listens on localhost",
|
||||
"server.connect.local.loopback.description":
|
||||
"Other devices can't reach a server that only listens on localhost. Opening it will only work on the computer running OpenCode.",
|
||||
"server.connect.local.loopback.open": "Open on this computer",
|
||||
"server.connect.local.loopback.fix": "Run this command on your computer, then pair again.",
|
||||
"command.server.pair": "Pair device",
|
||||
"settings.pairing.title": "Pairing",
|
||||
"settings.pairing.connection": "Local Network",
|
||||
"pair.local.description": "View connection details and a QR code to connect a device on the same network.",
|
||||
"pair.local.open": "Show details",
|
||||
"pair.screenActive.title": "Keep screen active",
|
||||
"pair.screenActive.description": "Prevent this computer’s display from sleeping while OpenCode is running.",
|
||||
"pair.screenActive.error": "Could not update the screen activity setting. Try again.",
|
||||
"pair.qr.open": "Show details",
|
||||
"pair.tailscale.enable": "Enable Tailscale",
|
||||
"pair.tailscale.serve": "Tailscale Serve",
|
||||
"pair.description": "Connect another device to this machine's OpenCode server.",
|
||||
"pair.loading": "Loading connection details…",
|
||||
"pair.qr": "Pairing QR code",
|
||||
"pair.copy": "Copy details",
|
||||
"pair.copy.error": "Could not copy pairing details. Try again.",
|
||||
"pair.urls": "URLs",
|
||||
"pair.username": "Username",
|
||||
"pair.password": "Password",
|
||||
"pair.error": "Could not update pairing details. Try again.",
|
||||
"pair.tailscale.open": "Share with Tailscale",
|
||||
"pair.tailscale.opening": "Enabling Tailscale Serve…",
|
||||
"pair.tailscale.title": "Tailscale",
|
||||
"pair.tailscale.description": "Use Tailscale Serve to share this server over HTTPS with devices on your tailnet.",
|
||||
"pair.tailscale.checking": "Checking Tailscale Serve…",
|
||||
"pair.tailscale.inactive": "This server is not shared with Tailscale yet.",
|
||||
"pair.tailscale.error": "Could not load Tailscale Serve status. Try again.",
|
||||
"pair.tailscale.disable": "Disable",
|
||||
"pair.tailscale.disabling": "Disabling Tailscale Serve…",
|
||||
"dialog.server.edit.title": "Edit server",
|
||||
"dialog.server.default.title": "Default server",
|
||||
"dialog.server.default.description":
|
||||
|
||||
@@ -22,12 +22,6 @@ type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||
type PlatformName = "web" | "desktop"
|
||||
type DesktopOS = "macos" | "windows" | "linux"
|
||||
|
||||
export type PairingInfo = {
|
||||
readonly urls: readonly string[]
|
||||
readonly username: "opencode"
|
||||
readonly password: string
|
||||
}
|
||||
|
||||
export type FatalRendererErrorLog = {
|
||||
error: string
|
||||
url: string
|
||||
@@ -107,10 +101,6 @@ type PlatformBase = {
|
||||
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
|
||||
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
|
||||
|
||||
/** Prevent the local display from sleeping while the desktop app is running. */
|
||||
getKeepScreenActive?(): Promise<boolean>
|
||||
setKeepScreenActive?(enabled: boolean): Promise<void>
|
||||
|
||||
/** Run a desktop-only menu action from the app chrome */
|
||||
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
|
||||
|
||||
@@ -134,15 +124,6 @@ type PlatformBase = {
|
||||
|
||||
/** Native browser pane hosted by the platform (desktop only). */
|
||||
browserPane?: BrowserPanePlatform
|
||||
|
||||
/** Pair another device with the local desktop server. */
|
||||
pair?: {
|
||||
info(): Promise<PairingInfo>
|
||||
tailscaleAvailable(): Promise<boolean>
|
||||
tailscaleStatus(): Promise<PairingInfo | null>
|
||||
openTailscale(): Promise<PairingInfo>
|
||||
disableTailscale(): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
|
||||
@@ -7,10 +7,6 @@ export function isMixedContent(page: string, address: string) {
|
||||
const url = new URL(normalized)
|
||||
if (url.protocol !== "http:") return false
|
||||
// Secure Contexts treats loopback HTTP origins as potentially trustworthy.
|
||||
return !isLoopback(url)
|
||||
}
|
||||
|
||||
export function isLoopback(url: URL) {
|
||||
const host = url.hostname.replace(/\.$/, "")
|
||||
return host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host)
|
||||
return !(host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host))
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createResource } from "solid-js"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
|
||||
export function createCameraAvailability() {
|
||||
const platform = usePlatform()
|
||||
const supported = platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
|
||||
const [available, actions] = createResource(
|
||||
async () => {
|
||||
if (!supported || !navigator.mediaDevices.enumerateDevices) return false
|
||||
const denied = await navigator.permissions?.query({ name: "camera" }).then(
|
||||
(permission) => permission.state === "denied",
|
||||
() => false,
|
||||
)
|
||||
if (denied) return false
|
||||
return navigator.mediaDevices.enumerateDevices().then(
|
||||
(devices) => devices.some((device) => device.kind === "videoinput"),
|
||||
() => false,
|
||||
)
|
||||
},
|
||||
{ initialValue: false },
|
||||
)
|
||||
return { supported, available, refetch: actions.refetch }
|
||||
}
|
||||
@@ -4,17 +4,7 @@ import { Divider } from "@opencode/ui/divider"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import {
|
||||
type Component,
|
||||
Show,
|
||||
Suspense,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
lazy,
|
||||
onCleanup,
|
||||
onMount,
|
||||
} from "solid-js"
|
||||
import { type Component, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import {
|
||||
createServerHealthPreview,
|
||||
@@ -28,12 +18,8 @@ import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import { decodePairingCode } from "./pairing"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
|
||||
type FormMode = "list" | "add" | "edit"
|
||||
|
||||
export const DialogServer: Component<{
|
||||
@@ -43,8 +29,6 @@ export const DialogServer: Component<{
|
||||
}> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const camera = createCameraAvailability()
|
||||
const form = createFormController({
|
||||
onSelect: (server) => {
|
||||
props.onSave?.(server)
|
||||
@@ -91,112 +75,64 @@ export const DialogServer: Component<{
|
||||
</DialogHeader>
|
||||
<Divider />
|
||||
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
|
||||
<Show
|
||||
when={!form.state.scanning()}
|
||||
fallback={
|
||||
<Suspense fallback={<p role="status">{language.t("server.connect.camera.starting")}</p>}>
|
||||
<PairingScanner
|
||||
onCancel={() => {
|
||||
form.scan.stop()
|
||||
void camera.refetch()
|
||||
}}
|
||||
onScan={form.scan.complete}
|
||||
/>
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.value()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
autofocus
|
||||
list="dialog-server-addresses"
|
||||
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<datalist id="dialog-server-addresses">
|
||||
{form.state.urls().map((url) => (
|
||||
<option value={url} />
|
||||
))}
|
||||
</datalist>
|
||||
<Show when={form.state.error()}>
|
||||
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
|
||||
{form.state.error()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.name()}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
|
||||
<TextInput
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<Show when={props.mode === "add" && platform.platform === "web"}>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="large"
|
||||
class="!w-full self-stretch"
|
||||
disabled={form.state.busy() || !camera.available.latest}
|
||||
aria-describedby={
|
||||
!camera.available.latest && !camera.available.loading
|
||||
? "dialog-server-camera-unavailable"
|
||||
: undefined
|
||||
}
|
||||
onClick={form.scan.start}
|
||||
>
|
||||
{language.t("server.connect.scan")}
|
||||
</Button>
|
||||
<Show when={!camera.available.latest && !camera.available.loading}>
|
||||
<span id="dialog-server-camera-unavailable" class="settings-server-dialog-hint">
|
||||
{language.t(
|
||||
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
|
||||
)}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.value()}
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
autofocus
|
||||
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<Show when={form.state.error()}>
|
||||
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
|
||||
{form.state.error()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
|
||||
<TextInput
|
||||
type="text"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.name()}
|
||||
placeholder={language.t("dialog.server.add.namePlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.name(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
|
||||
<TextInput
|
||||
type="password"
|
||||
appearance="large"
|
||||
class="!w-full self-stretch"
|
||||
value={form.state.password()}
|
||||
placeholder={language.t("dialog.server.add.passwordPlaceholder")}
|
||||
disabled={form.state.busy()}
|
||||
onInput={(event) => form.change.password(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<Show when={!form.state.scanning()}>
|
||||
<DialogFooter>
|
||||
<Button variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||
{submitLabel()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Show>
|
||||
<DialogFooter>
|
||||
<Button variant="neutral" disabled={form.state.busy()} onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="contrast" disabled={form.state.busy()} onClick={form.submit}>
|
||||
{submitLabel()}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -213,8 +149,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
mode: "list" as FormMode,
|
||||
originalUrl: undefined as string | undefined,
|
||||
values: { url: "", name: "", password: "" },
|
||||
urls: [] as string[],
|
||||
scanning: false,
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
})
|
||||
@@ -227,8 +161,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
mode: "list",
|
||||
originalUrl: undefined,
|
||||
values: { url: "", name: "", password: "" },
|
||||
urls: [],
|
||||
scanning: false,
|
||||
error: "",
|
||||
status: undefined,
|
||||
})
|
||||
@@ -333,16 +265,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
setStore("error", "")
|
||||
request.mutate()
|
||||
}
|
||||
const pair = (pairing: NonNullable<ReturnType<typeof decodePairingCode>>) => {
|
||||
healthPreview.cancel()
|
||||
setStore({
|
||||
values: { ...store.values, url: pairing.urls[0], password: pairing.password },
|
||||
urls: pairing.urls,
|
||||
scanning: false,
|
||||
error: "",
|
||||
})
|
||||
request.mutate()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (store.mode !== "edit") return
|
||||
@@ -359,8 +281,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
value: () => store.values.url,
|
||||
name: () => store.values.name,
|
||||
password: () => store.values.password,
|
||||
urls: () => store.urls,
|
||||
scanning: () => store.scanning,
|
||||
error: () => store.error,
|
||||
status: () => store.status,
|
||||
},
|
||||
@@ -369,11 +289,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
name: (value: string) => change("name", value),
|
||||
password: (value: string) => change("password", value),
|
||||
},
|
||||
scan: {
|
||||
start: () => setStore("scanning", true),
|
||||
stop: () => setStore("scanning", false),
|
||||
complete: pair,
|
||||
},
|
||||
start: { add: startAdd, edit: startEdit },
|
||||
reset,
|
||||
submit,
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
|
||||
|
||||
describe("pairing URL", () => {
|
||||
test("pairs with the current origin using credentials without server URLs", () => {
|
||||
const info = { username: "opencode" as const, password: "a+b & café" }
|
||||
const origin = "https://computer.tailnet.ts.net:49709"
|
||||
const url = new URL(pairingUrl(info, origin))
|
||||
|
||||
expect(url.origin).toBe(origin)
|
||||
expect(url.pathname).toBe("/connect")
|
||||
expect(JSON.parse(url.searchParams.get("data") ?? "")).toEqual(info)
|
||||
expect(decodePairingUrl(url.search, origin)).toEqual({ urls: [origin], password: info.password })
|
||||
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("encodes the pairing JSON in the data query parameter and decodes it", () => {
|
||||
const info = {
|
||||
urls: ["http://192.168.1.2:4096"],
|
||||
username: "opencode" as const,
|
||||
password: "a+b & café",
|
||||
}
|
||||
const url = new URL(pairingUrl(info, "https://example.com"))
|
||||
|
||||
expect(url.origin).toBe("https://example.com")
|
||||
expect(url.pathname).toBe("/connect")
|
||||
expect(url.searchParams.get("data")).toBe(JSON.stringify(info))
|
||||
expect(decodePairingUrl(url.search)).toEqual({
|
||||
urls: ["http://192.168.1.2:4096"],
|
||||
password: "a+b & café",
|
||||
})
|
||||
})
|
||||
|
||||
test("defaults to the hosted app for desktop pairing", () => {
|
||||
expect(new URL(pairingUrl({ urls: [], username: "opencode", password: "secret" })).origin).toBe(
|
||||
"https://app.opencode.ai",
|
||||
)
|
||||
})
|
||||
|
||||
test("accepts CLI base64url fragments", () => {
|
||||
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
|
||||
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
|
||||
urls: value.urls,
|
||||
password: value.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects invalid query data", () => {
|
||||
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
|
||||
expect(decodePairingUrl("?other=value")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("accepts legacy JSON fragments", () => {
|
||||
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
|
||||
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
|
||||
urls: value.urls,
|
||||
password: value.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects an invalid fragment", () => {
|
||||
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pairing scan", () => {
|
||||
const info = {
|
||||
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
|
||||
username: "opencode" as const,
|
||||
password: "a+b & café",
|
||||
}
|
||||
const decoded = { urls: info.urls, password: info.password }
|
||||
|
||||
test("decodes raw JSON, desktop query links, and the CLI fragment link", () => {
|
||||
expect(decodePairingScan(JSON.stringify(info))).toEqual(decoded)
|
||||
expect(decodePairingScan(pairingUrl(info, "http://192.168.1.2:49374"))).toEqual(decoded)
|
||||
const encoded = Buffer.from(JSON.stringify(info)).toString("base64url")
|
||||
expect(decodePairingScan(`https://app.opencode.ai/connect#${encoded}`)).toEqual(decoded)
|
||||
})
|
||||
|
||||
test("rejects URLs without pairing data and non-http schemes", () => {
|
||||
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
|
||||
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
|
||||
expect(decodePairingScan("not a code")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { normalizeServerUrl } from "@/runtime/server/registry"
|
||||
|
||||
const pairing = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
urls: Schema.optional(Schema.Array(Schema.String)),
|
||||
urls: Schema.Array(Schema.String),
|
||||
username: Schema.Literal("opencode"),
|
||||
password: Schema.String,
|
||||
}),
|
||||
@@ -19,46 +19,10 @@ export function serverAddress(value: string) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function decodePairingCode(value: string, origin?: string) {
|
||||
export function decodePairingCode(value: string) {
|
||||
const result = Schema.decodeUnknownOption(pairing)(value)
|
||||
if (Option.isNone(result)) return
|
||||
const urls = [
|
||||
...new Set(
|
||||
(result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined),
|
||||
),
|
||||
]
|
||||
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
|
||||
if (!urls.length) return
|
||||
return { urls, password: result.value.password }
|
||||
}
|
||||
|
||||
export function pairingUrl(
|
||||
value: { urls?: readonly string[]; username: "opencode"; password: string },
|
||||
host = "https://app.opencode.ai",
|
||||
) {
|
||||
return `${new URL("/connect", host)}?data=${encodeURIComponent(JSON.stringify(value))}`
|
||||
}
|
||||
|
||||
export function decodePairingScan(value: string) {
|
||||
const url = URL.parse(value)
|
||||
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
|
||||
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash)
|
||||
}
|
||||
|
||||
export function decodePairingUrl(value: string, origin?: string) {
|
||||
if (value.startsWith("?")) {
|
||||
const data = new URLSearchParams(value).get("data")
|
||||
return data === null ? undefined : decodePairingCode(data, origin)
|
||||
}
|
||||
const encoded = value.startsWith("#") ? value.slice(1) : value
|
||||
if (!encoded) return
|
||||
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
|
||||
if (legacy.startsWith("{")) return decodePairingCode(legacy)
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
|
||||
const binary = atob(
|
||||
encoded
|
||||
.replaceAll("-", "+")
|
||||
.replaceAll("_", "/")
|
||||
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
|
||||
)
|
||||
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))))
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
.server-connect-scanner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.server-connect-error {
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.server-connect-video {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.server-connect-video [role="status"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,10 @@ import { onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { decodePairingScan } from "./pairing"
|
||||
import "./scanner.css"
|
||||
import { decodePairingCode } from "./pairing"
|
||||
|
||||
export function PairingScanner(props: {
|
||||
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
|
||||
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
@@ -22,7 +21,7 @@ export function PairingScanner(props: {
|
||||
const scanner = new QrScanner(
|
||||
video,
|
||||
(result) => {
|
||||
const pairing = decodePairingScan(result.data)
|
||||
const pairing = decodePairingCode(result.data)
|
||||
if (!pairing) {
|
||||
setState("error", language.t("server.connect.scan.invalid"))
|
||||
return
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
form {
|
||||
form,
|
||||
.server-connect-scanner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
@@ -94,4 +95,25 @@
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
user-select: all;
|
||||
}
|
||||
|
||||
.server-connect-video {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.server-connect-video [role="status"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Show, Suspense } from "solid-js"
|
||||
import { createResource, lazy, Show, Suspense } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -9,28 +9,34 @@ import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { serverAddress } from "./pairing"
|
||||
import type { decodePairingCode } from "./pairing"
|
||||
import { isLoopback, isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import { isMixedContent } from "./browser"
|
||||
import "./screen.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
|
||||
export function ConnectServerScreen(
|
||||
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
|
||||
) {
|
||||
export function ConnectServerScreen() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const servers = useServers()
|
||||
const check = useCheckServerHealth()
|
||||
const camera = createCameraAvailability()
|
||||
const [state, setState] = createStore({
|
||||
url: props.pairing?.urls[0] ?? "",
|
||||
password: props.pairing?.password ?? "",
|
||||
urls: props.pairing?.urls ?? ([] as string[]),
|
||||
error: "",
|
||||
scanning: false,
|
||||
})
|
||||
const cameraSupported =
|
||||
platform.platform === "web" && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia
|
||||
const [camera, cameraActions] = createResource(
|
||||
async () => {
|
||||
if (!cameraSupported || !navigator.mediaDevices.enumerateDevices) return false
|
||||
const denied = await navigator.permissions?.query({ name: "camera" }).then(
|
||||
(permission) => permission.state === "denied",
|
||||
() => false,
|
||||
)
|
||||
if (denied) return false
|
||||
return navigator.mediaDevices.enumerateDevices().then(
|
||||
(devices) => devices.some((device) => device.kind === "videoinput"),
|
||||
() => false,
|
||||
)
|
||||
},
|
||||
{ initialValue: false },
|
||||
)
|
||||
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
|
||||
const connectionError = () =>
|
||||
language.t(
|
||||
platform.platform === "web" && isMixedContent(location.href, state.url)
|
||||
@@ -51,7 +57,6 @@ export function ConnectServerScreen(
|
||||
return
|
||||
}
|
||||
servers.add({ type: "http", http })
|
||||
props.onConnect?.()
|
||||
},
|
||||
onError: () => setState("error", connectionError()),
|
||||
}))
|
||||
@@ -73,7 +78,7 @@ export function ConnectServerScreen(
|
||||
<PairingScanner
|
||||
onCancel={() => {
|
||||
setState("scanning", false)
|
||||
void camera.refetch()
|
||||
void cameraActions.refetch()
|
||||
}}
|
||||
onScan={(pairing) => {
|
||||
setState({
|
||||
@@ -149,15 +154,13 @@ export function ConnectServerScreen(
|
||||
<Button
|
||||
variant="neutral"
|
||||
size="large"
|
||||
disabled={request.isPending || !camera.available.latest}
|
||||
aria-describedby={
|
||||
!camera.available.latest && !camera.available.loading ? "server-connect-camera-unavailable" : undefined
|
||||
}
|
||||
disabled={request.isPending || !camera.latest}
|
||||
aria-describedby={!camera.latest && !camera.loading ? "server-connect-camera-unavailable" : undefined}
|
||||
onClick={() => setState("scanning", true)}
|
||||
>
|
||||
{language.t("server.connect.scan")}
|
||||
</Button>
|
||||
<Show when={!camera.available.latest && !camera.available.loading}>
|
||||
<Show when={!camera.latest && !camera.loading}>
|
||||
<p id="server-connect-camera-unavailable">
|
||||
{language.t(
|
||||
window.isSecureContext ? "server.connect.camera.unavailable" : "server.connect.camera.insecure",
|
||||
@@ -174,37 +177,3 @@ export function ConnectServerScreen(
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConnectLocalScreen(props: { urls: readonly string[] }) {
|
||||
const language = useLanguage()
|
||||
const target = props.urls[0]
|
||||
const loopback = isLoopback(new URL(target))
|
||||
return (
|
||||
<main data-component="connect-server" aria-labelledby="server-connect-title">
|
||||
<div class="server-connect-content">
|
||||
<div class="server-connect-brand" role="img" aria-label="OpenCode">
|
||||
<Wordmark />
|
||||
</div>
|
||||
<header>
|
||||
<h1 id="server-connect-title">
|
||||
{language.t(loopback ? "server.connect.local.loopback.title" : "server.connect.local.title")}
|
||||
</h1>
|
||||
<p>{language.t(loopback ? "server.connect.local.loopback.description" : "server.connect.local.description")}</p>
|
||||
</header>
|
||||
<Button
|
||||
variant="contrast"
|
||||
size="large"
|
||||
onClick={() => location.assign(`${new URL("/connect", target)}${location.search}${location.hash}`)}
|
||||
>
|
||||
{language.t(loopback ? "server.connect.local.loopback.open" : "server.connect.local.open")}
|
||||
</Button>
|
||||
<footer>
|
||||
<Show when={loopback}>
|
||||
<p>{language.t("server.connect.local.loopback.fix")}</p>
|
||||
</Show>
|
||||
<code dir="ltr">{loopback ? "opencode service set hostname 0.0.0.0" : target}</code>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export const pageIcons = {
|
||||
appearance: "appearance",
|
||||
notifications: "notifications",
|
||||
shortcuts: "keyboard",
|
||||
pairing: "server",
|
||||
projects: "folder",
|
||||
workspaces: "outline-worktree",
|
||||
providers: "providers",
|
||||
@@ -23,7 +22,6 @@ export const pageLabels = {
|
||||
appearance: "settings.general.section.appearance",
|
||||
notifications: "settings.tab.notifications",
|
||||
shortcuts: "settings.shortcuts.title",
|
||||
pairing: "settings.pairing.title",
|
||||
projects: "settings.tab.projects",
|
||||
workspaces: "settings.tab.workspaces",
|
||||
providers: "settings.providers.title",
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||
import { renderSVG } from "uqr"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
|
||||
import { pairingUrl } from "@/servers/connect/pairing"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
|
||||
export function SettingsPairing() {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const platform = usePlatform()
|
||||
const queryClient = useQueryClient()
|
||||
const pair = platform.pair
|
||||
if (!pair) return null
|
||||
const local = useQuery(() => ({
|
||||
queryKey: ["pairing", "local"],
|
||||
queryFn: pair.info,
|
||||
}))
|
||||
// Reading pending query data would suspend the entire settings surface.
|
||||
const localInfo = () => (local.isSuccess ? local.data : undefined)
|
||||
const localHost = createMemo(() =>
|
||||
localInfo()?.urls.find((value) => {
|
||||
const host = new URL(value).hostname
|
||||
return (
|
||||
host !== "localhost" &&
|
||||
!host.endsWith(".localhost") &&
|
||||
!host.startsWith("127.") &&
|
||||
host !== "[::1]" &&
|
||||
host !== "0.0.0.0" &&
|
||||
host !== "[::]"
|
||||
)
|
||||
}),
|
||||
)
|
||||
const screenActive = useQuery(() => ({
|
||||
queryKey: ["pairing", "screen-active"],
|
||||
queryFn: () => platform.getKeepScreenActive!(),
|
||||
enabled: !!platform.getKeepScreenActive,
|
||||
}))
|
||||
const screenActivity = useMutation(() => ({
|
||||
mutationFn: async (enabled: boolean) => platform.setKeepScreenActive?.(enabled),
|
||||
onSuccess: (_, enabled) => queryClient.setQueryData(["pairing", "screen-active"], enabled),
|
||||
}))
|
||||
const tailscale = useQuery(() => ({
|
||||
queryKey: ["pairing", "tailscale-available"],
|
||||
queryFn: pair.tailscaleAvailable,
|
||||
}))
|
||||
const tailscaleStatus = useQuery(() => ({
|
||||
queryKey: ["pairing", "tailscale-status"],
|
||||
queryFn: pair.tailscaleStatus,
|
||||
enabled: tailscale.isSuccess && tailscale.data === true,
|
||||
}))
|
||||
const tailscaleServe = useMutation(() => ({
|
||||
mutationFn: pair.openTailscale,
|
||||
onSuccess: (value) => queryClient.setQueryData(["pairing", "tailscale-status"], value),
|
||||
}))
|
||||
const tailscaleDisable = useMutation(() => ({
|
||||
mutationFn: pair.disableTailscale,
|
||||
onSuccess: () => queryClient.setQueryData(["pairing", "tailscale-status"], null),
|
||||
}))
|
||||
const tailscaleInfo = () => (tailscaleStatus.isSuccess ? tailscaleStatus.data : undefined)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.pairing.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("pair.description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">
|
||||
<section class="settings-section" aria-label={language.t("settings.pairing.connection")}>
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.pairing.connection")}
|
||||
description={language.t("pair.local.description")}
|
||||
>
|
||||
<Button
|
||||
variant="neutral"
|
||||
disabled={!localHost()}
|
||||
onClick={() =>
|
||||
dialog.push(() => (
|
||||
<DialogPairing
|
||||
title={language.t("settings.pairing.connection")}
|
||||
info={localInfo()}
|
||||
host={localHost()}
|
||||
/>
|
||||
))
|
||||
}
|
||||
>
|
||||
{language.t("pair.local.open")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
<Show when={platform.getKeepScreenActive && platform.setKeepScreenActive}>
|
||||
<div data-action="settings-keep-screen-active">
|
||||
<SettingsRow
|
||||
title={language.t("pair.screenActive.title")}
|
||||
description={language.t("pair.screenActive.description")}
|
||||
>
|
||||
<Switch
|
||||
hideLabel
|
||||
checked={screenActive.isSuccess && screenActive.data}
|
||||
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
|
||||
onChange={(enabled) => screenActivity.mutate(enabled)}
|
||||
>
|
||||
{language.t("pair.screenActive.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</Show>
|
||||
</SettingsList>
|
||||
<Show when={screenActive.error || screenActivity.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.screenActive.error")}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={local.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.error")}
|
||||
</p>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<Show when={tailscale.isSuccess && tailscale.data === true}>
|
||||
<section class="settings-section" aria-label={language.t("pair.tailscale.title")}>
|
||||
<Show
|
||||
when={tailscaleInfo()}
|
||||
fallback={
|
||||
<>
|
||||
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
|
||||
<SettingsList>
|
||||
<div class="flex min-h-24 flex-col items-center justify-center gap-3 px-4 py-6">
|
||||
<Button
|
||||
variant="neutral"
|
||||
disabled={!localInfo() || tailscaleStatus.isPending || tailscaleServe.isPending}
|
||||
aria-busy={tailscaleServe.isPending}
|
||||
onClick={() => tailscaleServe.mutate()}
|
||||
>
|
||||
<svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<g opacity="0.3">
|
||||
<circle cx="3" cy="3" r="3" />
|
||||
<circle cx="12" cy="3" r="3" />
|
||||
<circle cx="21" cy="3" r="3" />
|
||||
<circle cx="3" cy="21" r="3" />
|
||||
<circle cx="21" cy="21" r="3" />
|
||||
</g>
|
||||
<circle cx="3" cy="12" r="3" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<circle cx="21" cy="12" r="3" />
|
||||
<circle cx="12" cy="21" r="3" />
|
||||
</svg>
|
||||
{language.t(tailscaleServe.isPending ? "pair.tailscale.opening" : "pair.tailscale.enable")}
|
||||
</Button>
|
||||
<span class="text-center text-[13px] leading-text-base text-v2-text-text-muted">
|
||||
{language.t("pair.tailscale.description")}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("pair.tailscale.serve")}
|
||||
description={language.t("pair.tailscale.description")}
|
||||
>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<Button
|
||||
variant="neutral"
|
||||
disabled={!tailscaleInfo() || tailscaleDisable.isPending}
|
||||
onClick={() =>
|
||||
dialog.push(() => (
|
||||
<DialogPairing
|
||||
title={language.t("pair.tailscale.title")}
|
||||
info={tailscaleInfo()}
|
||||
host={tailscaleInfo()?.urls[0]}
|
||||
/>
|
||||
))
|
||||
}
|
||||
>
|
||||
{language.t("pair.qr.open")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="neutral"
|
||||
disabled={
|
||||
!localInfo() ||
|
||||
tailscaleStatus.isPending ||
|
||||
tailscaleServe.isPending ||
|
||||
tailscaleDisable.isPending
|
||||
}
|
||||
onClick={() => tailscaleDisable.mutate()}
|
||||
>
|
||||
{language.t("pair.tailscale.disable")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
<Show when={tailscaleStatus.error || tailscaleServe.error || tailscaleDisable.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.tailscale.error")}
|
||||
</p>
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host?: string }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const url = createMemo(() => {
|
||||
if (!props.info) return
|
||||
const host = props.host ?? (platform.platform === "web" ? location.origin : undefined)
|
||||
return pairingUrl(
|
||||
{
|
||||
urls: host ? [host] : props.info.urls.slice(0, 1),
|
||||
username: props.info.username,
|
||||
password: props.info.password,
|
||||
},
|
||||
host,
|
||||
)
|
||||
})
|
||||
const origin = createMemo(() => {
|
||||
const value = url()
|
||||
if (!value) return
|
||||
return new URL(value).origin
|
||||
})
|
||||
const copy = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const value = url()
|
||||
if (!value) return
|
||||
await (platform.writeClipboardText?.(value) ?? navigator.clipboard.writeText(value))
|
||||
},
|
||||
}))
|
||||
createEffect(() => {
|
||||
if (!copy.isSuccess) return
|
||||
const timeout = setTimeout(() => copy.reset(), 2000)
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
})
|
||||
const qr = createMemo(() => {
|
||||
const value = url()
|
||||
if (!value) return
|
||||
return renderSVG(value, { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog fit containerClass="max-w-[min(400px,calc(100vw-32px),calc(100dvh-180px))]">
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
|
||||
</DialogHeader>
|
||||
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
|
||||
<Show when={props.info}>
|
||||
<div
|
||||
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
|
||||
role="img"
|
||||
aria-label={language.t("pair.qr")}
|
||||
innerHTML={qr()}
|
||||
/>
|
||||
<div class="flex min-w-0 justify-center pb-2">
|
||||
<Tooltip
|
||||
class="min-w-0 max-w-full"
|
||||
value={language.t(copy.isSuccess ? "common.copied" : "pair.copy")}
|
||||
placement="top"
|
||||
forceOpen={copy.isSuccess ? true : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-8 max-w-full select-none items-center justify-center gap-2 rounded-[6px] px-2 py-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-02 focus-visible:outline-none disabled:opacity-50"
|
||||
disabled={copy.isPending}
|
||||
aria-label={language.t("pair.copy")}
|
||||
onClick={() => copy.mutate()}
|
||||
>
|
||||
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
|
||||
<bdi dir="ltr" class="min-w-0 break-all text-start">
|
||||
{origin()}
|
||||
</bdi>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={copy.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.copy.error")}
|
||||
</p>
|
||||
</Show>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -20,20 +20,6 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
|
||||
{ tab: "appearance", label: "settings.general.section.appearance" },
|
||||
{ tab: "notifications", label: "settings.tab.notifications" },
|
||||
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
|
||||
{
|
||||
tab: "pairing",
|
||||
label: "settings.pairing.title",
|
||||
keywords: "pair device qr tailscale",
|
||||
available: "desktop",
|
||||
},
|
||||
{
|
||||
tab: "pairing",
|
||||
label: "pair.screenActive.title",
|
||||
description: "pair.screenActive.description",
|
||||
target: "settings-keep-screen-active",
|
||||
keywords: "display sleep awake local",
|
||||
available: "desktop",
|
||||
},
|
||||
{ tab: "experimental", label: "settings.tab.experimental" },
|
||||
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
|
||||
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
|
||||
|
||||
@@ -1517,12 +1517,6 @@
|
||||
color: var(--v2-state-fg-danger);
|
||||
}
|
||||
|
||||
.settings-server-dialog-hint {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-extensions-tabs[data-component="tabs-v2"][data-variant="pill"] > [data-slot="tabs-v2-list"] {
|
||||
width: min(280px, 100%);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createEffect, createMemo, on, onCleanup, onMount, Show, Switch, Match, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
@@ -19,7 +18,6 @@ import { SettingsAppearance } from "./appearance/appearance"
|
||||
import { SettingsExperimental } from "./experimental/experimental"
|
||||
import { SettingsKeybinds } from "./keybinds/keybinds"
|
||||
import { SettingsNotifications } from "./notifications/notifications"
|
||||
import { SettingsPairing } from "./pairing/pairing"
|
||||
import { SettingsProviders } from "./providers/providers"
|
||||
import { SettingsModels } from "./models/models"
|
||||
import { SettingsServerGeneral } from "./servers/servers"
|
||||
@@ -43,7 +41,6 @@ const rootClientTabs = [
|
||||
{ value: "appearance", icon: pageIcons.appearance, label: "settings.general.section.appearance" },
|
||||
{ value: "notifications", icon: pageIcons.notifications, label: "settings.tab.notifications" },
|
||||
{ value: "shortcuts", icon: pageIcons.shortcuts, label: "settings.tab.shortcuts" },
|
||||
{ value: "pairing", icon: pageIcons.pairing, label: "settings.pairing.title" },
|
||||
] as const
|
||||
|
||||
const serverTabs = [
|
||||
@@ -190,7 +187,6 @@ function RootSettings() {
|
||||
const tabs = useTabs()
|
||||
const servers = useServerCollectionController()
|
||||
const inventory = useSettingsServers()
|
||||
const platform = usePlatform()
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
const list = servers.collection.items
|
||||
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
|
||||
@@ -220,11 +216,7 @@ function RootSettings() {
|
||||
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
|
||||
))
|
||||
const groups = createMemo<SettingsNavGroup[]>(() => [
|
||||
{
|
||||
items: rootClientTabs
|
||||
.filter((item) => item.value !== "pairing" || !!platform.pair)
|
||||
.map((item) => ({ ...item, label: language.t(item.label) })),
|
||||
},
|
||||
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
|
||||
...(multiple()
|
||||
? [
|
||||
{
|
||||
@@ -290,9 +282,6 @@ function RootSettings() {
|
||||
<Tabs.Content value="shortcuts" class="settings-panel">
|
||||
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="pairing" class="settings-panel">
|
||||
<SettingsPairing />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="experimental" class="settings-panel">
|
||||
<SettingsExperimental />
|
||||
</Tabs.Content>
|
||||
|
||||
@@ -11,7 +11,6 @@ export type SettingsRootTab =
|
||||
| "appearance"
|
||||
| "notifications"
|
||||
| "shortcuts"
|
||||
| "pairing"
|
||||
| "projects"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
@@ -45,7 +44,6 @@ const rootTabs: Record<SettingsRootTab, true> = {
|
||||
appearance: true,
|
||||
notifications: true,
|
||||
shortcuts: true,
|
||||
pairing: true,
|
||||
projects: true,
|
||||
workspaces: true,
|
||||
providers: true,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCommand, type CommandOption } from "./command"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { DialogSsh } from "@/servers/ssh/dialog"
|
||||
import { useUpdaterAction } from "@/shell/updates/action"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
|
||||
export function DesktopCommands() {
|
||||
const command = useCommand()
|
||||
@@ -41,25 +40,3 @@ export function DesktopCommands() {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function DesktopPairingCommand() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettingsSurface()
|
||||
|
||||
command.register("desktop-pairing", () =>
|
||||
platform.platform === "desktop" && platform.pair
|
||||
? [
|
||||
{
|
||||
id: "server.pair",
|
||||
title: language.t("command.server.pair"),
|
||||
category: language.t("command.category.server"),
|
||||
onSelect: () => settings.open("pairing"),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Route, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Route, useParams } from "@solidjs/router"
|
||||
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Home } from "@/home/route"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -10,8 +10,6 @@ import { LayoutProvider } from "@/shell/state/layout"
|
||||
import { SettingsSurfaceProvider } from "@/settings/surface"
|
||||
import Shell from "@/shell/shell"
|
||||
import { requireServerKey } from "./session"
|
||||
import { decodePairingUrl } from "@/servers/connect/pairing"
|
||||
import { DesktopPairingCommand } from "@/shell/commands/desktop"
|
||||
|
||||
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
|
||||
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
|
||||
@@ -20,9 +18,6 @@ const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({
|
||||
const ConnectServerScreen = lazy(() =>
|
||||
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectServerScreen })),
|
||||
)
|
||||
const ConnectLocalScreen = lazy(() =>
|
||||
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectLocalScreen })),
|
||||
)
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
@@ -31,7 +26,6 @@ export function preloadRoute(url: string) {
|
||||
const pathname = url.split(/[?#]/, 1)[0]
|
||||
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
|
||||
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
|
||||
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
|
||||
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
|
||||
return TargetSessionRouteContent.preload().then(() => undefined)
|
||||
return Promise.resolve()
|
||||
@@ -39,53 +33,32 @@ export function preloadRoute(url: string) {
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route path="/connect" component={ConnectRoute} />
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
</Suspense>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
</>
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
</Suspense>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectRoute() {
|
||||
const navigate = useNavigate()
|
||||
const servers = useServers()
|
||||
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash)
|
||||
// From the hosted https page, http is mixed content and loopback is the scanning device itself.
|
||||
const url =
|
||||
pairing?.urls.find((item) => item === location.origin) ??
|
||||
pairing?.urls.find((item) => location.protocol !== "https:" || item.startsWith("https:"))
|
||||
onMount(() => {
|
||||
if (!pairing || !url) return
|
||||
servers.add({ type: "http", http: { url, password: pairing.password } })
|
||||
navigate("/", { replace: true })
|
||||
})
|
||||
if (!pairing) return <ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
|
||||
if (!url) return <ConnectLocalScreen urls={pairing.urls} />
|
||||
return null
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string }>()
|
||||
const global = useGlobal()
|
||||
@@ -106,7 +79,6 @@ function AppLayout(props: ParentProps) {
|
||||
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<DesktopPairingCommand />
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</BrowserAttachmentsProvider>
|
||||
|
||||
@@ -10,10 +10,6 @@ test("settings has its own layout route", () => {
|
||||
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
|
||||
})
|
||||
|
||||
test("connect has its own layout route", () => {
|
||||
expect(currentRoute("/connect", "")).toEqual({ type: "connect" })
|
||||
})
|
||||
|
||||
describe("layout persistence", () => {
|
||||
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
|
||||
@@ -67,7 +67,6 @@ export type TabPanes = {
|
||||
export type LayoutRoute =
|
||||
| { type: "home" }
|
||||
| { type: "settings" }
|
||||
| { type: "connect" }
|
||||
| { type: "draft"; draftID: string }
|
||||
| { type: "session"; sessionId: string; server: ServerConnection.Key }
|
||||
|
||||
@@ -107,7 +106,6 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
const parts = pathname.split("/").filter(Boolean)
|
||||
if (parts.length === 0) return { type: "home" }
|
||||
if (parts[0] === "settings") return { type: "settings" }
|
||||
if (parts[0] === "connect") return { type: "connect" }
|
||||
|
||||
if (parts[0] === "new-session") {
|
||||
const draftID = new URLSearchParams(search).get("draftId")
|
||||
|
||||
@@ -303,7 +303,6 @@ export function Titlebar(props: {
|
||||
return
|
||||
}
|
||||
case "settings":
|
||||
case "connect":
|
||||
case "home": {
|
||||
const selection = layout.home.selection()
|
||||
const conn =
|
||||
|
||||
@@ -2,7 +2,6 @@ import { EOL } from "os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
@@ -19,8 +18,6 @@ export default Runtime.handler(
|
||||
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
|
||||
)).urls
|
||||
const info = { urls, username: "opencode", password }
|
||||
// Fragment, not query: the credential must never reach app.opencode.ai.
|
||||
const link = `https://app.opencode.ai/connect#${base64Encode(JSON.stringify(info))}`
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
@@ -31,13 +28,11 @@ export default Runtime.handler(
|
||||
"",
|
||||
" Scan to pair",
|
||||
"",
|
||||
renderUnicodeCompact(link, { border: 2 })
|
||||
renderUnicodeCompact(JSON.stringify(info), { border: 2 })
|
||||
.split(EOL)
|
||||
.map((line) => " " + line)
|
||||
.join(EOL),
|
||||
"",
|
||||
` Link ${link}`,
|
||||
"",
|
||||
].join(EOL) + EOL,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,16 +10,18 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
|
||||
? Effect.succeed(options.assets)
|
||||
: yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
|
||||
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
|
||||
return yield* api.pipe(
|
||||
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
|
||||
)
|
||||
return yield* assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
|
||||
})
|
||||
api.pipe(
|
||||
Effect.catchIf(isRouteNotFound, () =>
|
||||
HttpServerRequest.HttpServerRequest.pipe(
|
||||
Effect.flatMap((request) => {
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
|
||||
return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
|
||||
return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
|
||||
import { ServerProcess } from "@opencode/server/process"
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
@@ -8,69 +7,11 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { WebUi } from "../src/services/web-ui"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
|
||||
afterAll(() => rm(root, { recursive: true, force: true }))
|
||||
|
||||
describe("web UI", () => {
|
||||
it.live("serves the web shell and assets before server authentication", () =>
|
||||
Effect.gen(function* () {
|
||||
const transform = yield* WebUi.handler({
|
||||
assets: {
|
||||
"index.html": "<html><body>connect</body></html>",
|
||||
"_assets/app.js": "console.log('connect')",
|
||||
"_assets/app.css": "body { color: black; }",
|
||||
"icons/icon.svg": "<svg></svg>",
|
||||
"font.woff2": new Uint8Array([0, 1, 2, 255]),
|
||||
"sw.js": "service worker",
|
||||
},
|
||||
})
|
||||
const server = yield* ServerProcess.start<never, never>(
|
||||
{ hostname: "127.0.0.1", port: 0, password: "secret", database: { path: ":memory:" } },
|
||||
undefined,
|
||||
transform,
|
||||
)
|
||||
const origin = HttpServer.formatAddress(server.address)
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
"/",
|
||||
"/connect?data=%7B%7D",
|
||||
"/workspace/example",
|
||||
"/_assets/app.js",
|
||||
"/_assets/app.css",
|
||||
"/icons/icon.svg",
|
||||
"/font.woff2",
|
||||
"/sw.js",
|
||||
],
|
||||
(pathname) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(["GET", "HEAD"], (method) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin), { method }))
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("www-authenticate")).toBeNull()
|
||||
yield* Effect.promise(() => response.arrayBuffer())
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* Effect.forEach(["/api", "/api/info", "/api/event", "/api/missing", "/openapi.json"], (pathname) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin)))
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
|
||||
yield* Effect.promise(() => response.arrayBuffer())
|
||||
}),
|
||||
)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/info", origin), { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
test("falls back from API routes to assets and the SPA index", async () => {
|
||||
const index = path.join(root, "index.html")
|
||||
const asset = path.join(root, "app.js")
|
||||
|
||||
@@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See
|
||||
|
||||
## How it differs from JavaScript
|
||||
|
||||
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
|
||||
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the
|
||||
globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable.
|
||||
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
|
||||
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
|
||||
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
|
||||
@@ -94,11 +94,19 @@ receive `{ extension, name, args }`. An `after` hook also receives how the call
|
||||
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
|
||||
failure as a thrown error.
|
||||
|
||||
### `Values`
|
||||
### `Extension.make`
|
||||
|
||||
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
|
||||
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
|
||||
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
|
||||
Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the
|
||||
catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean.
|
||||
|
||||
```ts
|
||||
const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } })
|
||||
const runtime = CodeMode.make({ tools, extensions: [web] })
|
||||
```
|
||||
|
||||
Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as
|
||||
copies, and a function inside a result is callable the same way. A global that shadows a built-in or another
|
||||
extension throws at `CodeMode.make`.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
|
||||
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
|
||||
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode.
|
||||
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Uint8Array values inside CodeMode.
|
||||
- [x] Tool calls through the host-provided `tools` tree only.
|
||||
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
|
||||
shadowable by program declarations like other globals.
|
||||
@@ -47,8 +47,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
## Values and literals
|
||||
|
||||
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
iterators, and synchronous generators.
|
||||
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, custom
|
||||
synchronous iterators, and synchronous generators.
|
||||
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
|
||||
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
|
||||
- [x] Template literals with interpolation.
|
||||
@@ -95,8 +95,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `if`/`else` and conditional expressions.
|
||||
- [x] `switch`, including default clauses and fallthrough.
|
||||
- [x] `for`, `while`, and `do...while`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
|
||||
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, custom synchronous iterators, and
|
||||
confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
@@ -127,7 +127,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
|
||||
because `includes` is called without a string `this`.
|
||||
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`,
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
|
||||
like JS.
|
||||
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
|
||||
arrow function.
|
||||
@@ -179,10 +179,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Sequence expressions (the comma operator).
|
||||
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
|
||||
`await` still defers its continuation one reaction turn.
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
|
||||
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
|
||||
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
|
||||
non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new`
|
||||
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
|
||||
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
|
||||
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
@@ -451,7 +451,13 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
|
||||
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
|
||||
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
|
||||
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
|
||||
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
|
||||
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
|
||||
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`;
|
||||
iteration is live and sorted by name, with `set-cookie` values kept apart.
|
||||
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
|
||||
- [ ] `Request`, `Response`, and `Blob`.
|
||||
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
|
||||
|
||||
## Extensions
|
||||
|
||||
@@ -461,8 +467,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
|
||||
a built-in or another extension throws at `make`.
|
||||
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
|
||||
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
|
||||
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
|
||||
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
|
||||
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
record,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./interpreter/objects.js"
|
||||
import { typeofValue } from "./interpreter/references.js"
|
||||
|
||||
@@ -69,6 +70,7 @@ const walk = <R>(
|
||||
)
|
||||
}
|
||||
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
|
||||
if (value instanceof HeadersObj) return Object.fromEntries(value.headers)
|
||||
const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value
|
||||
if (stack.has(target)) throw typeError("Converting circular structure to JSON.")
|
||||
stack.add(target)
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
import { describeValue } from "./references.js"
|
||||
|
||||
@@ -52,6 +53,7 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
|
||||
if (value instanceof URLObj) return new URL(value.url.href)
|
||||
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
|
||||
if (value instanceof HeadersObj) return new Headers(value.headers)
|
||||
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
|
||||
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
|
||||
if (value instanceof SetObj) return new Set([...value.set].map(next))
|
||||
@@ -125,6 +127,7 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof URLSearchParams) {
|
||||
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
|
||||
}
|
||||
if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value))
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new MapObj(builtins.Map)
|
||||
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
|
||||
|
||||
@@ -11,6 +11,7 @@ import { objectGlobal } from "../stdlib/object.js"
|
||||
import { regexpGlobal } from "../stdlib/regexp.js"
|
||||
import { stringGlobal } from "../stdlib/string.js"
|
||||
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
|
||||
import { headersGlobal } from "../stdlib/headers.js"
|
||||
import { coercion } from "../stdlib/value.js"
|
||||
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
@@ -80,6 +81,7 @@ const table: Record<string, Factory> = {
|
||||
Set: (ctx) => setGlobal(ctx),
|
||||
URL: (ctx) => urlGlobal(ctx),
|
||||
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
|
||||
Headers: (ctx) => headersGlobal(ctx),
|
||||
Uint8Array: (ctx) => uint8ArrayGlobal(ctx),
|
||||
TextEncoder: (ctx) => textEncoderGlobal(ctx),
|
||||
TextDecoder: (ctx) => textDecoderGlobal(ctx),
|
||||
|
||||
@@ -84,6 +84,7 @@ import {
|
||||
PromiseObj,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
record,
|
||||
remove,
|
||||
set,
|
||||
@@ -653,7 +654,7 @@ class Frame<R> {
|
||||
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
|
||||
if (iterator === undefined && cursor === undefined) {
|
||||
throw invalidData(
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
@@ -756,9 +757,11 @@ class Frame<R> {
|
||||
? value.set.values()
|
||||
: value instanceof URLSearchParamsObj
|
||||
? value.params.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: undefined
|
||||
: value instanceof HeadersObj
|
||||
? value.headers.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: undefined
|
||||
if (iterator !== undefined) {
|
||||
const proto = this.ctx.builtins.Array
|
||||
return Effect.succeed({
|
||||
@@ -1848,6 +1851,7 @@ class Frame<R> {
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
) {
|
||||
const cursor = yield* self.iterate(value, node)
|
||||
|
||||
@@ -29,6 +29,7 @@ const builtins = [
|
||||
"Set",
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"Headers",
|
||||
"Uint8Array",
|
||||
"TextEncoder",
|
||||
"TextDecoder",
|
||||
@@ -80,6 +81,7 @@ export const createBuiltins = (): Builtins => {
|
||||
Set: plain(),
|
||||
URL: plain(),
|
||||
URLSearchParams: plain(),
|
||||
Headers: plain(),
|
||||
Uint8Array: plain(),
|
||||
TextEncoder: plain(),
|
||||
TextDecoder: plain(),
|
||||
|
||||
@@ -156,6 +156,15 @@ export class URLSearchParamsObj extends Obj {
|
||||
}
|
||||
}
|
||||
|
||||
export class HeadersObj extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly headers: Headers,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
}
|
||||
|
||||
export class URLObj extends Obj {
|
||||
readonly searchParams: URLSearchParamsObj
|
||||
constructor(
|
||||
@@ -181,13 +190,14 @@ export class Bytes extends Obj {
|
||||
/** Built-in objects that wrap a host value; data-like, but never plain data. */
|
||||
export const isWrapper = (
|
||||
value: unknown,
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | Bytes =>
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
|
||||
value instanceof DateObj ||
|
||||
value instanceof RegExpObj ||
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
|
||||
const MAX_ARRAY_INDEX = 4_294_967_295
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
|
||||
/** Values that cannot cross the data boundary. */
|
||||
@@ -85,6 +86,7 @@ export const describeValue = (value: unknown): string => {
|
||||
if (value instanceof SetObj) return "a Set"
|
||||
if (value instanceof URLObj) return "a URL"
|
||||
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
|
||||
if (value instanceof HeadersObj) return "a Headers"
|
||||
if (value instanceof Bytes) return "a Uint8Array"
|
||||
if (value instanceof GeneratorObj) return "a generator"
|
||||
if (isRuntimeReference(value)) return "a function"
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
@@ -66,6 +67,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
|
||||
if (value instanceof RegExpObj) return coerceToString(value)
|
||||
if (value instanceof URLObj) return coerceToString(value)
|
||||
if (value instanceof URLSearchParamsObj) return coerceToString(value)
|
||||
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
|
||||
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { typeError } from "../interpreter/model.js"
|
||||
import { entries, Arr, HeadersObj, Obj } from "../interpreter/objects.js"
|
||||
import { applyCollectionCallback } from "../interpreter/callback.js"
|
||||
import { isRuntimeReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
import { readPairs } from "./url.js"
|
||||
|
||||
// The host validates header names and values and throws its own TypeError; the program gets one of its own.
|
||||
const attempt = <T>(run: () => T): T => {
|
||||
try {
|
||||
return run()
|
||||
} catch (error) {
|
||||
throw typeError(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
const constructHeaders = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
|
||||
const wrap = (headers: Headers) => new HeadersObj(proto, headers)
|
||||
if (init === undefined) return Effect.succeed(wrap(new Headers()))
|
||||
return Effect.gen(function* () {
|
||||
const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined
|
||||
if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs)))
|
||||
if (!(init instanceof Obj) || isRuntimeReference(init)) {
|
||||
throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.")
|
||||
}
|
||||
return wrap(
|
||||
attempt(() => new Headers(Object.fromEntries(entries(init).map(([key, value]) => [key, coerceToString(value)])))),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export const headersGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const proto = builtins.Headers
|
||||
const headers = constructor<R>(builtins, proto, {
|
||||
name: "Headers",
|
||||
call: requiresNew("Headers"),
|
||||
construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
|
||||
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
|
||||
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
|
||||
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
|
||||
if (args.length < count) throw typeError(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
|
||||
}
|
||||
methods(builtins, proto, [
|
||||
[
|
||||
"append",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
requireArgs("append", args, 2)
|
||||
const target = self(thisValue, "append").headers
|
||||
return attempt(() => target.append(arg(args, 0), arg(args, 1)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"delete",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("delete", args, 1)
|
||||
const target = self(thisValue, "delete").headers
|
||||
return attempt(() => target.delete(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"get",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("get", args, 1)
|
||||
const target = self(thisValue, "get").headers
|
||||
return attempt(() => target.get(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
["getSetCookie", 0, (thisValue) => wrap(self(thisValue, "getSetCookie").headers.getSetCookie())],
|
||||
[
|
||||
"has",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("has", args, 1)
|
||||
const target = self(thisValue, "has").headers
|
||||
return attempt(() => target.has(arg(args, 0)))
|
||||
},
|
||||
],
|
||||
[
|
||||
"set",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
requireArgs("set", args, 2)
|
||||
const target = self(thisValue, "set").headers
|
||||
return attempt(() => target.set(arg(args, 0), arg(args, 1)))
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").headers.keys()))],
|
||||
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").headers.values()))],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
wrap(Array.from(self(thisValue, "entries").headers.entries(), ([key, value]) => wrap([key, value]))),
|
||||
],
|
||||
[
|
||||
"forEach",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
requireArgs("forEach", args, 1)
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
],
|
||||
])
|
||||
return headers
|
||||
}
|
||||
@@ -107,12 +107,10 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return url
|
||||
}
|
||||
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
|
||||
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(value)
|
||||
if (cursor === undefined) {
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
|
||||
const items: Array<string> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -126,6 +124,29 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<s
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Reads a synchronous iterable of `[name, value]` pairs as strings; `undefined` when `init` is not iterable. As in
|
||||
* WebIDL, the whole sequence is converted before any pair's length is checked.
|
||||
*/
|
||||
export const readPairs = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
init: unknown,
|
||||
label: string,
|
||||
): Effect.Effect<Array<[string, string]> | undefined, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor === undefined) return undefined
|
||||
const pairs: Array<Array<string>> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
if (pairs.some((entry) => entry.length !== 2)) throw typeError(`${label} expects iterable [name, value] pairs.`)
|
||||
return pairs as Array<[string, string]>
|
||||
}
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value, label)))
|
||||
}
|
||||
})
|
||||
|
||||
const constructURLSearchParams = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
init: unknown,
|
||||
@@ -139,20 +160,8 @@ const constructURLSearchParams = <R>(
|
||||
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor !== undefined) {
|
||||
const pairs: Array<Array<string>> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
if (pairs.some((entry) => entry.length !== 2)) {
|
||||
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
|
||||
}
|
||||
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
|
||||
}
|
||||
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value)))
|
||||
}
|
||||
}
|
||||
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
|
||||
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
|
||||
if (isRuntimeReference(init)) {
|
||||
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
|
||||
@@ -28,6 +29,7 @@ export const coerceToString = (value: unknown): string => {
|
||||
if (value instanceof SetObj) return "[object Set]"
|
||||
if (value instanceof URLObj) return value.url.href
|
||||
if (value instanceof URLSearchParamsObj) return value.params.toString()
|
||||
if (value instanceof HeadersObj) return "[object Headers]"
|
||||
if (value instanceof Bytes) return value.bytes.join(",")
|
||||
if (value instanceof ErrorObj) {
|
||||
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
|
||||
|
||||
@@ -128,6 +128,34 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 })
|
||||
})
|
||||
|
||||
test("Headers cross as copies in both directions", async () => {
|
||||
const stored = new Headers({ "X-A": "1" })
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "http",
|
||||
globals: {
|
||||
headers: () => stored,
|
||||
keep: (value: Headers) => {
|
||||
held.push(value)
|
||||
return value
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
held.length = 0
|
||||
expect(
|
||||
await value(
|
||||
`const h = headers(); h.set("x-a", "2"); const back = keep(h); back.set("x-a", "3"); return [h instanceof Headers, h.get("x-a"), back === h, back.get("x-a"), [...back]]`,
|
||||
target,
|
||||
),
|
||||
).toEqual([true, "2", false, "3", [["x-a", "3"]]])
|
||||
expect(stored.get("x-a")).toBe("1")
|
||||
expect(held[0]).toBeInstanceOf(Headers)
|
||||
expect((held[0] as Headers).get("x-a")).toBe("2")
|
||||
})
|
||||
|
||||
test("bytes cross as copies in both directions; ArrayBuffer comes in as Uint8Array", async () => {
|
||||
const stored = new Uint8Array([1, 2, 3])
|
||||
const target = CodeMode.make({
|
||||
|
||||
@@ -635,6 +635,154 @@ describe("URL and URI helpers", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Headers", () => {
|
||||
test("constructs from records, pairs, Maps, and Headers; names fold to lowercase and values combine", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const headers = new Headers({ "Content-Type": "text/plain", "X-Count": 1, "X-Null": null })
|
||||
headers.append("Accept", "text/html")
|
||||
headers.append("accept", "application/json")
|
||||
headers.set("x-count", "2")
|
||||
headers.delete("x-null")
|
||||
const copy = new Headers(headers)
|
||||
copy.set("content-type", "text/html")
|
||||
return {
|
||||
get: headers.get("content-type"),
|
||||
missing: headers.get("x-missing"),
|
||||
combined: headers.get("ACCEPT"),
|
||||
has: [headers.has("Accept"), headers.has("x-null")],
|
||||
count: headers.get("x-count"),
|
||||
copied: [headers.get("content-type"), copy.get("content-type")],
|
||||
pairs: [...new Headers([["b", "2"], ["A", "1"]])],
|
||||
map: [...new Headers(new Map([["k", "v"]]))],
|
||||
keys: headers.keys(),
|
||||
values: headers.values(),
|
||||
entries: headers.entries(),
|
||||
}
|
||||
`),
|
||||
).toEqual({
|
||||
get: "text/plain",
|
||||
missing: null,
|
||||
combined: "text/html, application/json",
|
||||
has: [true, false],
|
||||
count: "2",
|
||||
copied: ["text/plain", "text/html"],
|
||||
pairs: [
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
],
|
||||
map: [["k", "v"]],
|
||||
keys: ["accept", "content-type", "x-count"],
|
||||
values: ["text/html, application/json", "text/plain", "2"],
|
||||
entries: [
|
||||
["accept", "text/html, application/json"],
|
||||
["content-type", "text/plain"],
|
||||
["x-count", "2"],
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("iterates in sorted order everywhere iteration is allowed, and getSetCookie keeps cookies apart", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const headers = new Headers({ b: "2", a: "1" })
|
||||
headers.append("Set-Cookie", "x=1")
|
||||
headers.append("set-cookie", "y=2")
|
||||
const seen = []
|
||||
headers.forEach((value, name, self) => seen.push(name + "=" + value + ":" + (self === headers)))
|
||||
const [first] = headers
|
||||
function* pairs() { yield* headers }
|
||||
return {
|
||||
seen,
|
||||
first,
|
||||
spread: [...headers],
|
||||
from: Array.from(headers).length,
|
||||
generator: [...pairs()].length,
|
||||
object: Object.fromEntries(headers),
|
||||
cookies: headers.getSetCookie(),
|
||||
}
|
||||
`),
|
||||
).toEqual({
|
||||
seen: ["a=1:true", "b=2:true", "set-cookie=x=1:true", "set-cookie=y=2:true"],
|
||||
first: ["a", "1"],
|
||||
spread: [
|
||||
["a", "1"],
|
||||
["b", "2"],
|
||||
["set-cookie", "x=1"],
|
||||
["set-cookie", "y=2"],
|
||||
],
|
||||
from: 4,
|
||||
generator: 4,
|
||||
object: { a: "1", b: "2", "set-cookie": "y=2" },
|
||||
cookies: ["x=1", "y=2"],
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes as a name-to-value object at the boundary and in JSON; prints for console", async () => {
|
||||
const result = await run(`
|
||||
const headers = new Headers({ "X-A": "1", b: "2" })
|
||||
console.log(headers)
|
||||
return { headers, json: JSON.stringify({ headers }), text: String(headers), type: typeof headers, is: headers instanceof Headers }
|
||||
`)
|
||||
expect(result.ok && result.value).toEqual({
|
||||
headers: { b: "2", "x-a": "1" },
|
||||
json: '{"headers":{"b":"2","x-a":"1"}}',
|
||||
text: "[object Headers]",
|
||||
type: "object",
|
||||
is: true,
|
||||
})
|
||||
expect(result.ok && result.logs?.[0]).toBe('Headers {"b":"2","x-a":"1"}')
|
||||
})
|
||||
|
||||
test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
function message(run) {
|
||||
try { run(); return null } catch (error) { return error instanceof TypeError ? error.message : error }
|
||||
}
|
||||
const headers = new Headers()
|
||||
return [
|
||||
message(() => Headers()),
|
||||
message(() => new Headers(null)),
|
||||
message(() => new Headers(1)),
|
||||
message(() => new Headers("a=1")),
|
||||
message(() => new Headers(new Date())),
|
||||
message(() => new Headers(() => 1)),
|
||||
message(() => new Headers([["name"]])),
|
||||
message(() => new Headers([["a", "b", "c"]])),
|
||||
message(() => new Headers({ "bad name": "x" })),
|
||||
message(() => new Headers({ name: "bad\u0000value" })),
|
||||
message(() => headers.get("invalid\u0100")),
|
||||
message(() => headers.has({})),
|
||||
message(() => headers.set("a", "invalid\u0100")),
|
||||
message(() => headers.append("a")),
|
||||
message(() => headers.forEach()),
|
||||
message(() => headers.forEach(1)),
|
||||
message(() => { const get = headers.get; return get("a") }),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
"Constructor Headers requires 'new'.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
|
||||
"new Headers(...) expects iterable [name, value] pairs.",
|
||||
"new Headers(...) expects iterable [name, value] pairs.",
|
||||
expect.stringContaining("bad name"),
|
||||
expect.stringContaining("invalid value"),
|
||||
expect.stringContaining("Invalid header name"),
|
||||
expect.stringContaining("[object Object]"),
|
||||
expect.stringContaining("invalid value"),
|
||||
"Headers.append requires 2 arguments.",
|
||||
"Headers.forEach requires 1 argument.",
|
||||
"Headers.forEach expects a function callback.",
|
||||
"Headers.prototype.get called on incompatible receiver undefined.",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Map", () => {
|
||||
test("get/set/has/size with chaining", async () => {
|
||||
expect(
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
|
||||
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
|
||||
* - WebCryptoAPI/randomUUID.https.any.js
|
||||
* - fetch/api/headers/{headers-basic,headers-errors}.any.js
|
||||
*
|
||||
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
|
||||
*
|
||||
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException.
|
||||
* Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns
|
||||
* arrays), or a custom iterator on a Headers instance are left out.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
@@ -166,3 +169,225 @@ describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)",
|
||||
).toEqual([true, true, true, 768])
|
||||
})
|
||||
})
|
||||
|
||||
// Enough of testharness.js to run the Headers files close to verbatim; each `test` records its failure, if any.
|
||||
const testharness = `
|
||||
const failures = []
|
||||
function test(run, name) { try { run() } catch (error) { failures.push(name + ": " + (error && error.message ? error.message : error)) } }
|
||||
function assert_equals(actual, expected, message) { if (actual !== expected) throw new Error((message || "") + " expected " + JSON.stringify(expected) + " got " + JSON.stringify(actual)) }
|
||||
function assert_true(actual, message) { assert_equals(actual, true, message) }
|
||||
function assert_false(actual, message) { assert_equals(actual, false, message) }
|
||||
function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) }
|
||||
function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") }
|
||||
function assert_unreached() { throw new Error("unreachable") }
|
||||
`
|
||||
|
||||
describe("Headers WPT parity (fetch/api/headers)", () => {
|
||||
test("headers-basic.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
test(function() { new Headers() }, "Create headers from no parameter")
|
||||
test(function() { new Headers(undefined) }, "Create headers from undefined parameter")
|
||||
test(function() { new Headers({}) }, "Create headers from empty object")
|
||||
var parameters = [null, 1]
|
||||
parameters.forEach(function(parameter) {
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers(parameter) }) }, "Create headers with " + parameter + " should throw")
|
||||
})
|
||||
var headerDict = {"name1": "value1", "name2": "value2", "name3": "value3", "name4": null, "name5": undefined, "name6": 1, "Content-Type": "value4"}
|
||||
var headerSeq = []
|
||||
for (var name in headerDict) headerSeq.push([name, headerDict[name]])
|
||||
test(function() {
|
||||
var headers = new Headers(headerSeq)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary")
|
||||
}, "Create headers with sequence")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}, "Create headers with record")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
var headers2 = new Headers(headers)
|
||||
for (name in headerDict) assert_equals(headers2.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}, "Create headers with existing headers")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (name in headerDict) {
|
||||
headers.append(name, headerDict[name])
|
||||
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}
|
||||
}, "Check append method")
|
||||
test(function() {
|
||||
var headers = new Headers()
|
||||
for (name in headerDict) {
|
||||
headers.set(name, headerDict[name])
|
||||
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
}
|
||||
}, "Check set method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_true(headers.has(name), "headers has name " + name)
|
||||
assert_false(headers.has("nameNotInHeaders"), "headers do not have header: nameNotInHeaders")
|
||||
}, "Check has method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) {
|
||||
assert_true(headers.has(name), "headers have a header: " + name)
|
||||
headers.delete(name)
|
||||
assert_true(!headers.has(name), "headers do not have anymore a header: " + name)
|
||||
}
|
||||
}, "Check delete method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerDict)
|
||||
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
|
||||
assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value")
|
||||
}, "Check get method")
|
||||
var headerEntriesDict = {"name1": "value1", "Name2": "value2", "name": "value3", "content-Type": "value4", "Content-Typ": "value5", "Content-Types": "value6"}
|
||||
var sortedHeaderDict = {}
|
||||
var headerValues = []
|
||||
var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) {
|
||||
sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]
|
||||
headerValues.push(headerEntriesDict[value])
|
||||
return value.toLowerCase()
|
||||
}).sort()
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.keys(), sortedHeaderKeys)
|
||||
for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1)
|
||||
}, "Check keys method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.values(), sortedHeaderKeys.map((key) => sortedHeaderDict[key]))
|
||||
for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1)
|
||||
}, "Check values method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals(headers.entries(), sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
|
||||
for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]])
|
||||
}, "Check entries method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
assert_array_equals([...headers], sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
|
||||
}, "Check Symbol.iterator method")
|
||||
test(function() {
|
||||
var headers = new Headers(headerEntriesDict)
|
||||
var index = 0
|
||||
headers.forEach(function(value, key, container) {
|
||||
assert_equals(headers, container)
|
||||
assert_equals(key, sortedHeaderKeys[index])
|
||||
assert_equals(value, sortedHeaderDict[sortedHeaderKeys[index]])
|
||||
index++
|
||||
})
|
||||
assert_equals(index, sortedHeaderKeys.length)
|
||||
}, "Check forEach method")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
headers.delete("foo")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz"])
|
||||
assert_array_equals(actualValues, ["0", "1"])
|
||||
}, "Iteration skips elements removed while iterating")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.delete("bar")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "quux"])
|
||||
assert_array_equals(actualValues, ["0", "1", "3"])
|
||||
}, "Removing elements already iterated over causes an element to be skipped during iteration")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.append("X-yZ", "4")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"])
|
||||
assert_array_equals(actualValues, ["0", "1", "2", "3", "4"])
|
||||
}, "Appending a value pair during iteration causes it to be reached during iteration")
|
||||
test(() => {
|
||||
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
|
||||
const actualKeys = []
|
||||
const actualValues = []
|
||||
for (const [header, value] of headers) {
|
||||
actualKeys.push(header)
|
||||
actualValues.push(value)
|
||||
if (header === "baz") headers.append("abc", "-1")
|
||||
}
|
||||
assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"])
|
||||
assert_array_equals(actualValues, ["0", "1", "1", "2", "3"])
|
||||
}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("headers-errors.any.js", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
${testharness}
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["name"]]) }) }, "Create headers giving an array having one string as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]) }) }, "Create headers giving an array having three strings as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid\u0100", "Value1"]]) }) }, "Create headers giving bad header name as init argument")
|
||||
test(function() { assert_throws_js(TypeError, function() { new Headers([["name", "invalidValue\u0100"]]) }) }, "Create headers giving bad header value as init argument")
|
||||
var badNames = ["invalid\u0100", {}]
|
||||
var badValues = ["invalid\u0100"]
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.get(name) }) }, "Check headers get with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.delete(name) }) }, "Check headers delete with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.has(name) }) }, "Check headers has with an invalid name " + name)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set(name, "Value1") }) }, "Check headers set with an invalid name " + name)
|
||||
})
|
||||
badValues.forEach(function(value) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set("name", value) }) }, "Check headers set with an invalid value " + value)
|
||||
})
|
||||
badNames.forEach(function(name) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("invalid\u0100", "Value1") }) }, "Check headers append with an invalid name " + name)
|
||||
})
|
||||
badValues.forEach(function(value) {
|
||||
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("name", value) }) }, "Check headers append with an invalid value " + value)
|
||||
})
|
||||
test(function() {
|
||||
var headers = new Headers([["name", "value"]])
|
||||
assert_throws_js(TypeError, function() { headers.forEach() })
|
||||
assert_throws_js(TypeError, function() { headers.forEach(undefined) })
|
||||
assert_throws_js(TypeError, function() { headers.forEach(1) })
|
||||
}, "Headers forEach throws if argument is not callable")
|
||||
test(function() {
|
||||
var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]])
|
||||
var counter = 0
|
||||
try {
|
||||
headers.forEach(function(value, name) {
|
||||
counter++
|
||||
if (name == "name2") throw "error"
|
||||
})
|
||||
} catch (e) {
|
||||
assert_equals(counter, 2)
|
||||
assert_equals(e, "error")
|
||||
return
|
||||
}
|
||||
assert_unreached()
|
||||
}, "Headers forEach loop should stop if callback is throwing exception")
|
||||
return failures
|
||||
`),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,10 +93,9 @@ export const prepare = Effect.fn("SessionPrompt.prepare")(function* (request: {
|
||||
const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(function* (
|
||||
input: PromptInput.FileAttachment,
|
||||
) {
|
||||
const label = attachmentLabel(input)
|
||||
const resolved = input.uri.startsWith("data:")
|
||||
? {
|
||||
bytes: yield* decodeDataURL(input.uri, label),
|
||||
bytes: yield* decodeDataURL(input.uri),
|
||||
source: { type: "inline" as const },
|
||||
start: undefined,
|
||||
end: undefined,
|
||||
@@ -106,8 +105,8 @@ const materializeAttachment = Effect.fn("SessionPrompt.materializeAttachment")(f
|
||||
: yield* readFileAttachment(input.uri)
|
||||
if (resolved.bytes.byteLength > MAX_ATTACHMENT_BYTES)
|
||||
return yield* new AttachmentError({
|
||||
uri: label,
|
||||
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${label}`,
|
||||
uri: input.uri,
|
||||
message: `Attachment exceeds the ${MAX_ATTACHMENT_BYTES} byte limit: ${input.uri}`,
|
||||
})
|
||||
|
||||
const mime = resolved.mime ?? Mime.detect(resolved.bytes)
|
||||
@@ -139,7 +138,7 @@ const normalizeImageAttachment = Effect.fn("SessionPrompt.normalizeImageAttachme
|
||||
) {
|
||||
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
|
||||
const image = yield* Image.Service
|
||||
const label = attachmentLabel(input)
|
||||
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
|
||||
const content = { uri: label, content: data, encoding: "base64" as const, mime }
|
||||
const normalized = yield* image.normalize(label, content).pipe(
|
||||
Effect.catchTag("Image.ResizerUnavailableError", () => Effect.succeed(content)),
|
||||
@@ -202,12 +201,7 @@ const readFileAttachment = Effect.fn("SessionPrompt.readFileAttachment")(functio
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
|
||||
|
||||
// A data URL is the whole file; errors and logs must name the attachment, not echo its bytes.
|
||||
function attachmentLabel(input: PromptInput.FileAttachment) {
|
||||
return input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
|
||||
}
|
||||
|
||||
function decodeDataURL(uri: string, label: string) {
|
||||
function decodeDataURL(uri: string) {
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
const comma = uri.indexOf(",")
|
||||
@@ -220,7 +214,7 @@ function decodeDataURL(uri: string, label: string) {
|
||||
if (bytes.toString("base64") !== payload) throw new Error("Non-canonical base64")
|
||||
return bytes
|
||||
},
|
||||
catch: () => new AttachmentError({ uri: label, message: `Invalid attachment data URL: ${label}` }),
|
||||
catch: () => new AttachmentError({ uri, message: "Invalid attachment data URL" }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -501,31 +501,8 @@ describe("Session.prompt", () => {
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "Session.AttachmentError",
|
||||
uri: "image.png",
|
||||
message: "Invalid attachment data URL: image.png",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects oversized inline attachments without echoing their bytes", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup
|
||||
const session = yield* Session.Service
|
||||
const uri = `data:application/octet-stream;base64,${Buffer.alloc(20 * 1024 * 1024 + 1).toString("base64")}`
|
||||
|
||||
const error = yield* session
|
||||
.prompt({
|
||||
sessionID,
|
||||
text: "Inspect this",
|
||||
files: [{ uri }],
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
_tag: "Session.AttachmentError",
|
||||
uri: "inline attachment",
|
||||
message: "Attachment exceeds the 20971520 byte limit: inline attachment",
|
||||
uri,
|
||||
message: "Invalid attachment data URL",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -45,10 +45,6 @@ function selectOptions(): DevOptions {
|
||||
async function prepareServer(source: ServerSource) {
|
||||
if (source.type === "download")
|
||||
return downloadCliToResources(source.version, windowsify("resources/opencode-cli-dev"))
|
||||
await $`bun run --cwd ${join(import.meta.dirname, "../../app")} build`.env({
|
||||
...process.env,
|
||||
VITE_OPENCODE_SERVER_MODE: "origin",
|
||||
})
|
||||
process.env.OPENCODE_DESKTOP_CLI_DEV = join(import.meta.dirname, "../../cli")
|
||||
await $`bun run --cwd ${process.env.OPENCODE_DESKTOP_CLI_DEV} --define=OPENCODE_VERSION=${JSON.stringify(process.env.OPENCODE_VERSION)} src/index.ts --version`
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AppRpcs } from "../../shared/ipc-rpc"
|
||||
import { openExternalURL } from "../files"
|
||||
import { checkAppExists, resolveAppPath } from "../files/apps"
|
||||
import { setForceFocus } from "../native/debug"
|
||||
import { createScreenActivity } from "../native/screen-activity"
|
||||
import { showCliInstaller } from "../native/install-cli"
|
||||
import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { createMenu, sendMenuCommand } from "../native/menu"
|
||||
@@ -18,8 +17,6 @@ import { DesktopCli } from "../service/desktop-cli"
|
||||
import { SidecarCredentials } from "../service/sidecar-credentials"
|
||||
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
|
||||
import { Updater } from "../updater"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { createPairing } from "../service/pairing"
|
||||
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
|
||||
import { sender } from "./context"
|
||||
|
||||
@@ -31,10 +28,6 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
const desktopCli = yield* DesktopCli.Service
|
||||
const updater = yield* Updater.Service
|
||||
const logging = yield* DesktopLogging.Service
|
||||
const storage = yield* DesktopStorage.Service
|
||||
const screenActivity = createScreenActivity(storage)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(screenActivity.dispose))
|
||||
const pairing = createPairing(storage)
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
return AppRpcs.of({
|
||||
AppAwaitInitialization: () => background.connection.pipe(Effect.map(SidecarCredentials.ready)),
|
||||
@@ -75,14 +68,6 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
})
|
||||
}),
|
||||
AppRelaunch: () => Effect.sync(lifecycle.relaunch),
|
||||
AppPairInfo: () => pair(pairing.info),
|
||||
AppGetKeepScreenActive: () => Effect.sync(screenActivity.get),
|
||||
AppSetKeepScreenActive: ({ enabled }) =>
|
||||
Effect.try(() => screenActivity.set(enabled)).pipe(Effect.mapError(String)),
|
||||
AppPairTailscaleAvailable: () => Effect.promise(pairing.tailscaleAvailable),
|
||||
AppPairTailscaleStatus: () => pair(pairing.tailscaleStatus),
|
||||
AppPairOpenTailscale: () => pair(pairing.openTailscale),
|
||||
AppPairDisableTailscale: () => pair(pairing.disableTailscale),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -90,10 +75,3 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
function promise<A>(evaluate: () => A | Promise<A>) {
|
||||
return Effect.tryPromise(async () => evaluate()).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
function pair<A>(evaluate: () => Promise<A>) {
|
||||
return Effect.tryPromise({
|
||||
try: evaluate,
|
||||
catch: (cause) => (cause instanceof Error ? cause.message : String(cause)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { MessagePortMain, WebContents } from "electron"
|
||||
import { Context, Effect, Layer, Option, Queue, Stream } from "effect"
|
||||
import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
|
||||
import { createIpcCodec } from "../shared/ipc-codec"
|
||||
import { bindIpcEvents } from "./ipc-events"
|
||||
|
||||
type PortBinding = {
|
||||
readonly id: number
|
||||
readonly sender: WebContents
|
||||
readonly port: MessagePortMain
|
||||
readonly parser: ReturnType<typeof createIpcCodec>
|
||||
readonly parser: RpcSerialization.Parser
|
||||
readonly onMessage: (event: Electron.MessageEvent) => void
|
||||
readonly onClose: () => void
|
||||
readonly unbindEvents: Effect.Effect<void>
|
||||
@@ -59,7 +58,7 @@ export const IpcServerProtocolLive = Layer.unwrap(
|
||||
}
|
||||
|
||||
const id = nextClientId++
|
||||
const parser = createIpcCodec(serialization)
|
||||
const parser = serialization.makeUnsafe()
|
||||
const onMessage = (event: Electron.MessageEvent) => {
|
||||
try {
|
||||
parser
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { powerSaveBlocker } from "electron"
|
||||
import type { DesktopStorage } from "../storage"
|
||||
import { KEEP_SCREEN_ACTIVE_KEY, SETTINGS_STORE } from "../storage/keys"
|
||||
|
||||
export function createScreenActivity(storage: DesktopStorage.Interface) {
|
||||
const state = { blocker: undefined as number | undefined }
|
||||
const dispose = () => {
|
||||
if (state.blocker === undefined) return
|
||||
powerSaveBlocker.stop(state.blocker)
|
||||
state.blocker = undefined
|
||||
}
|
||||
const set = (enabled: boolean) => {
|
||||
if (enabled && state.blocker === undefined) {
|
||||
state.blocker = powerSaveBlocker.start("prevent-display-sleep")
|
||||
}
|
||||
if (!enabled) dispose()
|
||||
storage.state.set(SETTINGS_STORE, KEEP_SCREEN_ACTIVE_KEY, JSON.stringify(enabled))
|
||||
}
|
||||
if (storage.state.get(SETTINGS_STORE, KEEP_SCREEN_ACTIVE_KEY) === "true") set(true)
|
||||
return {
|
||||
get: () => state.blocker !== undefined && powerSaveBlocker.isStarted(state.blocker),
|
||||
set,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
|
||||
? path.join(app.getPath("userData"), "opencode", "service-local.json")
|
||||
: undefined,
|
||||
version,
|
||||
command: [...cli.command, "serve", "--service", ...(isolated ? ["--hostname", "0.0.0.0", "--port", "0"] : [])],
|
||||
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
|
||||
onStart: (reason, previousVersion) =>
|
||||
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
|
||||
}),
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { tailscaleUrls } from "./pairing"
|
||||
|
||||
describe("Tailscale Serve output", () => {
|
||||
test("extracts and deduplicates HTTPS addresses", () => {
|
||||
expect(
|
||||
tailscaleUrls(`
|
||||
Available within your tailnet:
|
||||
https://computer.example.ts.net/
|
||||
|-- https://computer.example.ts.net
|
||||
|--> http://127.0.0.1:4096
|
||||
`),
|
||||
).toEqual(["https://computer.example.ts.net"])
|
||||
})
|
||||
|
||||
test("ignores non-HTTPS addresses", () => {
|
||||
expect(tailscaleUrls("http://computer:80\ntcp://100.64.0.1:443")).toEqual([])
|
||||
})
|
||||
|
||||
test("selects only the OpenCode HTTPS port", () => {
|
||||
expect(
|
||||
tailscaleUrls(
|
||||
"https://computer.example.ts.net\nhttps://computer.example.ts.net:8443\nhttps://computer.example.ts.net:49152",
|
||||
49152,
|
||||
),
|
||||
).toEqual(["https://computer.example.ts.net:49152"])
|
||||
})
|
||||
})
|
||||
@@ -1,164 +0,0 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { execFile } from "node:child_process"
|
||||
import { constants } from "node:fs"
|
||||
import { access } from "node:fs/promises"
|
||||
import { createServer } from "node:net"
|
||||
import { promisify } from "node:util"
|
||||
import type { DesktopStorage } from "../storage"
|
||||
import { pairing } from "../storage/schema"
|
||||
import { SidecarCredentials } from "./sidecar-credentials"
|
||||
|
||||
const tailscalePortKey = "tailscale_https_port"
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export function createPairing(storage: DesktopStorage.Interface) {
|
||||
let tailscale: Promise<string | undefined> | undefined
|
||||
const getTailscale = () => (tailscale ??= resolveTailscale())
|
||||
let tailscalePort: Promise<number> | undefined
|
||||
const storedTailscalePort = () => {
|
||||
const value = storage.db
|
||||
.select({ value: pairing.value })
|
||||
.from(pairing)
|
||||
.where(eq(pairing.key, tailscalePortKey))
|
||||
.get()?.value
|
||||
if (!value) return
|
||||
const port = Number(value)
|
||||
if (Number.isInteger(port) && port > 0 && port <= 65_535) return port
|
||||
}
|
||||
const getTailscalePort = () => (tailscalePort ??= Promise.resolve(storedTailscalePort() ?? availablePort()))
|
||||
const saveTailscalePort = (port: number) =>
|
||||
storage.db
|
||||
.insert(pairing)
|
||||
.values({ key: tailscalePortKey, value: String(port) })
|
||||
.onConflictDoUpdate({ target: pairing.key, set: { value: String(port) } })
|
||||
.run()
|
||||
|
||||
const requireCredentials = () => {
|
||||
const credentials = SidecarCredentials.get()
|
||||
if (!credentials) throw new Error("The local desktop server is not ready")
|
||||
return credentials
|
||||
}
|
||||
const readInfo = async (credentials: ReturnType<typeof requireCredentials>) => {
|
||||
const { OpenCode } = await import("@opencode/client/promise")
|
||||
const info = await OpenCode.make({
|
||||
baseUrl: credentials.url,
|
||||
headers: credentials.password
|
||||
? { Authorization: `Basic ${Buffer.from(`opencode:${credentials.password}`).toString("base64")}` }
|
||||
: undefined,
|
||||
}).server.info()
|
||||
return { urls: info.urls, username: "opencode" as const, password: credentials.password ?? "" }
|
||||
}
|
||||
const info = () => readInfo(requireCredentials())
|
||||
const serveTailscale = async (executable: string, port: number) => {
|
||||
const credentials = requireCredentials()
|
||||
const local = await readInfo(credentials)
|
||||
const endpoint = new URL(credentials.url)
|
||||
const options = { env: { ...process.env, TAILSCALE_BE_CLI: "1" }, windowsHide: true }
|
||||
const served = await execFileAsync(
|
||||
executable,
|
||||
["serve", `--https=${port}`, "--bg", "--yes", `http://127.0.0.1:${endpoint.port}`],
|
||||
options,
|
||||
)
|
||||
saveTailscalePort(port)
|
||||
const direct = tailscaleUrls(`${served.stdout}\n${served.stderr}`, port)
|
||||
const urls = direct.length
|
||||
? direct
|
||||
: tailscaleUrls(
|
||||
await execFileAsync(executable, ["serve", "status"], options).then(
|
||||
(result) => `${result.stdout}\n${result.stderr}`,
|
||||
),
|
||||
port,
|
||||
)
|
||||
if (!urls.length) throw new Error("Tailscale Serve did not report an HTTPS address")
|
||||
return { ...local, urls: [...urls, ...local.urls] }
|
||||
}
|
||||
|
||||
return {
|
||||
info,
|
||||
async tailscaleAvailable() {
|
||||
return (await getTailscale()) !== undefined
|
||||
},
|
||||
async tailscaleStatus() {
|
||||
const executable = await getTailscale()
|
||||
const port = storedTailscalePort()
|
||||
if (!executable || !port) return null
|
||||
return serveTailscale(executable, port)
|
||||
},
|
||||
async openTailscale() {
|
||||
const executable = await getTailscale()
|
||||
if (!executable) throw new Error("Tailscale is not installed")
|
||||
return serveTailscale(executable, await getTailscalePort())
|
||||
},
|
||||
async disableTailscale() {
|
||||
const executable = await getTailscale()
|
||||
const port = storedTailscalePort()
|
||||
if (!executable || !port) return
|
||||
await execFileAsync(executable, ["serve", `--https=${port}`, "--yes", "off"], {
|
||||
env: { ...process.env, TAILSCALE_BE_CLI: "1" },
|
||||
windowsHide: true,
|
||||
})
|
||||
storage.db.delete(pairing).where(eq(pairing.key, tailscalePortKey)).run()
|
||||
tailscalePort = undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTailscale() {
|
||||
const candidates =
|
||||
process.platform === "darwin"
|
||||
? [
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
...(process.env.HOME ? [`${process.env.HOME}/Applications/Tailscale.app/Contents/MacOS/Tailscale`] : []),
|
||||
"/opt/homebrew/bin/tailscale",
|
||||
"/usr/local/bin/tailscale",
|
||||
]
|
||||
: []
|
||||
const installed = (
|
||||
await Promise.all(
|
||||
candidates.map((file) =>
|
||||
access(file, constants.X_OK).then(
|
||||
() => file,
|
||||
() => undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
).find((file) => file !== undefined)
|
||||
if (installed) return installed
|
||||
const result = await execFileAsync(process.platform === "win32" ? "where.exe" : "which", ["tailscale"], {
|
||||
windowsHide: true,
|
||||
}).then(
|
||||
(value) => value.stdout,
|
||||
() => undefined,
|
||||
)
|
||||
return result
|
||||
?.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean)
|
||||
}
|
||||
|
||||
export function tailscaleUrls(output: string, port?: number) {
|
||||
return [
|
||||
...new Set(
|
||||
(output.match(/https:\/\/[^\s|]+/g) ?? [])
|
||||
.map((value) => URL.parse(value.replace(/[),;]+$/, "")))
|
||||
.filter((url): url is URL => url?.protocol === "https:" && (port === undefined || url.port === String(port)))
|
||||
.map((url) => url.href.replace(/\/$/, "")),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function availablePort() {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.once("error", reject)
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === "string") {
|
||||
server.close()
|
||||
reject(new Error("Could not allocate a Tailscale HTTPS port"))
|
||||
return
|
||||
}
|
||||
server.close((error) => (error ? reject(error) : resolve(address.port)))
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -14,7 +14,7 @@ const tables = (db: ReturnType<typeof drizzle>) =>
|
||||
describe("database", () => {
|
||||
test("bootstraps every table on a fresh database and is idempotent", () => {
|
||||
const database = openDatabase(":memory:")
|
||||
expect(tables(database.db)).toEqual(["blob", "document", "migration", "pairing", "state"])
|
||||
expect(tables(database.db)).toEqual(["blob", "document", "migration", "state"])
|
||||
expect(migrate(database.db)).toEqual([])
|
||||
database.close()
|
||||
})
|
||||
@@ -26,7 +26,7 @@ describe("database", () => {
|
||||
)
|
||||
const db = drizzle({ client: native })
|
||||
expect(migrate(db)).toEqual(migrations.map((migration) => migration.id))
|
||||
expect(tables(db)).toEqual(["blob", "document", "migration", "pairing", "state"])
|
||||
expect(tables(db)).toEqual(["blob", "document", "migration", "state"])
|
||||
expect(db.all<{ value: string }>(sql`SELECT value FROM document`)).toEqual([{ value: "v" }])
|
||||
expect(migrate(db)).toEqual([])
|
||||
})
|
||||
|
||||
@@ -3,6 +3,5 @@ export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
||||
export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
|
||||
export const WSL_SERVERS_KEY = "wslServers"
|
||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||
export const KEEP_SCREEN_ACTIVE_KEY = "keepScreenActive"
|
||||
export const BACKGROUND_COLOR_KEY = "backgroundColor"
|
||||
export const WINDOW_IDS_KEY = "windowIds"
|
||||
|
||||
@@ -18,8 +18,4 @@ export const migrations = [
|
||||
id: "20260907031611_blob-touched",
|
||||
statements: ["ALTER TABLE `blob` ADD `touched_at` integer DEFAULT 0 NOT NULL;"],
|
||||
},
|
||||
{
|
||||
id: "20260915071310_pairing-state",
|
||||
statements: ["CREATE TABLE `pairing` (\n\t`key` text PRIMARY KEY,\n\t`value` text NOT NULL\n);"],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
CREATE TABLE `pairing` (
|
||||
`key` text PRIMARY KEY,
|
||||
`value` text NOT NULL
|
||||
);
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "bfb5a007-b9ab-4835-be4e-45f0bd6d3ddb",
|
||||
"prevIds": [
|
||||
"53c65132-8703-42d6-8464-64356145dfb4"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "blob",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "document",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "pairing",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "state",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "blob"
|
||||
},
|
||||
{
|
||||
"type": "blob",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "data",
|
||||
"entityType": "columns",
|
||||
"table": "blob"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "0",
|
||||
"generated": null,
|
||||
"name": "touched_at",
|
||||
"entityType": "columns",
|
||||
"table": "blob"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "key",
|
||||
"entityType": "columns",
|
||||
"table": "document"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "value",
|
||||
"entityType": "columns",
|
||||
"table": "document"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "key",
|
||||
"entityType": "columns",
|
||||
"table": "pairing"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "value",
|
||||
"entityType": "columns",
|
||||
"table": "pairing"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "key",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "value",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "updated_at",
|
||||
"entityType": "columns",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"name",
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "state_pk",
|
||||
"entityType": "pks",
|
||||
"table": "state"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "blob_pk",
|
||||
"table": "blob",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "document_pk",
|
||||
"table": "document",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "pairing_pk",
|
||||
"table": "pairing",
|
||||
"entityType": "pks"
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
@@ -27,10 +27,3 @@ export const state = sqliteTable(
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.name, table.key] })],
|
||||
)
|
||||
|
||||
// Main-process pairing integration state. Tailscale Serve persists its configuration, and this
|
||||
// remembers which HTTPS port belongs to OpenCode so it can be recovered without touching other routes.
|
||||
export const pairing = sqliteTable("pairing", {
|
||||
key: text().primaryKey(),
|
||||
value: text().notNull(),
|
||||
})
|
||||
|
||||
@@ -15,7 +15,6 @@ import type {
|
||||
ServerReadyData,
|
||||
TitlebarTheme,
|
||||
} from "../shared/ipc-contract"
|
||||
import type { PairingInfo } from "../shared/ipc-rpc/app"
|
||||
|
||||
export type WslServersAPI = WslServersPlatform
|
||||
export type UpdaterAPI = {
|
||||
@@ -88,11 +87,4 @@ export type ElectronAPI = {
|
||||
setForceFocus(enabled: boolean): Promise<void>
|
||||
recordFatalRendererError(error: FatalRendererError): Promise<void>
|
||||
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
|
||||
pairInfo(): Promise<typeof PairingInfo.Type>
|
||||
getKeepScreenActive(): Promise<boolean>
|
||||
setKeepScreenActive(enabled: boolean): Promise<void>
|
||||
pairTailscaleAvailable(): Promise<boolean>
|
||||
pairTailscaleStatus(): Promise<typeof PairingInfo.Type | null>
|
||||
pairOpenTailscale(): Promise<typeof PairingInfo.Type>
|
||||
pairDisableTailscale(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -149,11 +149,4 @@ export const api: ElectronAPI = {
|
||||
setForceFocus: (enabled) => invoke("AppSetForceFocus", { enabled }),
|
||||
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
|
||||
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
|
||||
pairInfo: () => invoke("AppPairInfo").then(mutable),
|
||||
getKeepScreenActive: () => invoke("AppGetKeepScreenActive"),
|
||||
setKeepScreenActive: (enabled) => invoke("AppSetKeepScreenActive", { enabled }),
|
||||
pairTailscaleAvailable: () => invoke("AppPairTailscaleAvailable"),
|
||||
pairTailscaleStatus: () => invoke("AppPairTailscaleStatus").then(mutable),
|
||||
pairOpenTailscale: () => invoke("AppPairOpenTailscale").then(mutable),
|
||||
pairDisableTailscale: () => invoke("AppPairDisableTailscale"),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Context, Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"
|
||||
import { RpcClient, RpcMessage, RpcSerialization } from "effect/unstable/rpc"
|
||||
import { createIpcCodec } from "../shared/ipc-codec"
|
||||
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
|
||||
import type { DesktopEvent } from "../shared/ipc-rpc/events"
|
||||
import { IpcTransportPort } from "../shared/ipc-transport"
|
||||
@@ -86,7 +85,7 @@ function clientProtocol(value: MessagePort) {
|
||||
RpcClient.Protocol.make(
|
||||
Effect.fnUntraced(function* (writeResponse, clientIds) {
|
||||
const serialization = yield* RpcSerialization.RpcSerialization
|
||||
const parser = createIpcCodec(serialization)
|
||||
const parser = serialization.makeUnsafe()
|
||||
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
|
||||
@@ -79,8 +79,6 @@ export function createDesktopPlatform(
|
||||
windowFullscreen,
|
||||
getPinchZoomEnabled: () => api.getPinchZoomEnabled(),
|
||||
setPinchZoomEnabled,
|
||||
getKeepScreenActive: () => api.getKeepScreenActive(),
|
||||
setKeepScreenActive: (enabled) => api.setKeepScreenActive(enabled),
|
||||
onDragCancel: (callback) => {
|
||||
window.addEventListener(DragCancelEvent, callback)
|
||||
return () => window.removeEventListener(DragCancelEvent, callback)
|
||||
@@ -89,13 +87,6 @@ export function createDesktopPlatform(
|
||||
checkAppExists: async (appName) => {
|
||||
return api.checkAppExists(appName)
|
||||
},
|
||||
pair: {
|
||||
info: () => api.pairInfo(),
|
||||
tailscaleAvailable: () => api.pairTailscaleAvailable(),
|
||||
tailscaleStatus: () => api.pairTailscaleStatus(),
|
||||
openTailscale: () => api.pairOpenTailscale(),
|
||||
disableTailscale: () => api.pairDisableTailscale(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RpcSerialization } from "effect/unstable/rpc"
|
||||
import { createIpcCodec, ipcLargeMessageBytes } from "./ipc-codec"
|
||||
|
||||
type Serialization = RpcSerialization.RpcSerialization["Service"]
|
||||
|
||||
const msgpack = RpcSerialization.makeMsgPack()
|
||||
|
||||
function counting(serialization: Serialization) {
|
||||
let created = 0
|
||||
const counted: Serialization = {
|
||||
...serialization,
|
||||
makeUnsafe: () => {
|
||||
created++
|
||||
return serialization.makeUnsafe()
|
||||
},
|
||||
}
|
||||
return { created: () => created, serialization: counted }
|
||||
}
|
||||
|
||||
describe("ipc codec", () => {
|
||||
test("posts an exact-size buffer instead of a view into the shared target", () => {
|
||||
const codec = createIpcCodec(msgpack)
|
||||
// The msgpack target starts at 8 KiB, so a view would drag a larger backing store along.
|
||||
const encoded = codec.encode({ _tag: "Request", id: "1", tag: "Ping", payload: {} })
|
||||
expect(encoded).toBeInstanceOf(Uint8Array)
|
||||
const bytes = encoded as Uint8Array
|
||||
expect(bytes.byteOffset).toBe(0)
|
||||
expect(bytes.buffer.byteLength).toBe(bytes.byteLength)
|
||||
})
|
||||
|
||||
test("copies Node Buffers, whose slice is only a view", () => {
|
||||
const backing = new ArrayBuffer(1024 * 1024)
|
||||
const view = Buffer.from(backing, 16, 32)
|
||||
view.fill(7)
|
||||
const stub: Serialization = { ...msgpack, makeUnsafe: () => ({ decode: () => [], encode: () => view }) }
|
||||
const encoded = createIpcCodec(stub).encode({}) as Uint8Array
|
||||
expect(encoded.buffer).not.toBe(backing)
|
||||
expect(encoded.buffer.byteLength).toBe(32)
|
||||
expect([...encoded]).toEqual(Array(32).fill(7))
|
||||
})
|
||||
|
||||
test("replaces the encoder after a large message and keeps the decoder", () => {
|
||||
const spy = counting(msgpack)
|
||||
const codec = createIpcCodec(spy.serialization)
|
||||
expect(spy.created()).toBe(2)
|
||||
codec.encode({ small: true })
|
||||
expect(spy.created()).toBe(2)
|
||||
codec.encode({ large: "x".repeat(ipcLargeMessageBytes) })
|
||||
expect(spy.created()).toBe(3)
|
||||
codec.decode(codec.encode({ after: 1 }) as Uint8Array)
|
||||
expect(spy.created()).toBe(3)
|
||||
})
|
||||
|
||||
test("round-trips messages through the wrapped serialization", () => {
|
||||
const client = createIpcCodec(msgpack)
|
||||
const server = createIpcCodec(msgpack)
|
||||
const message = { _tag: "Request", id: "7", tag: "DraftsSet", payload: { key: "k", value: "v" } }
|
||||
expect(server.decode(client.encode(message) as Uint8Array)).toEqual([message])
|
||||
})
|
||||
})
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { RpcSerialization } from "effect/unstable/rpc"
|
||||
|
||||
// After a message this large the encoder is replaced so its grown target buffer can be collected.
|
||||
export const ipcLargeMessageBytes = 1024 * 1024
|
||||
|
||||
// The MessagePack parser packs into one shared, grow-only target buffer and returns a view into it.
|
||||
// Posting that view structured-clones the whole backing buffer, so after one large message every
|
||||
// later message (even a 55-byte ack) would copy the full grown buffer across processes on each
|
||||
// send. Encoding through this wrapper posts an exact-size copy instead. Decoding keeps a single
|
||||
// parser for the connection: record structures the peer defined inline must stay known.
|
||||
export function createIpcCodec(serialization: RpcSerialization.RpcSerialization["Service"]) {
|
||||
const decoder = serialization.makeUnsafe()
|
||||
let encoder = serialization.makeUnsafe()
|
||||
return {
|
||||
decode: (bytes: Uint8Array | string) => decoder.decode(bytes),
|
||||
encode(message: unknown) {
|
||||
const encoded = encoder.encode(message)
|
||||
if (!(encoded instanceof Uint8Array)) return encoded
|
||||
// Not `.slice()`: in the main process the packer hands out a Node Buffer, whose slice is a view.
|
||||
const copy = new Uint8Array(encoded)
|
||||
if (copy.byteLength > ipcLargeMessageBytes) encoder = serialization.makeUnsafe()
|
||||
return copy
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,6 @@ const ServerReadyData = Schema.Struct({
|
||||
url: Schema.String,
|
||||
})
|
||||
|
||||
export const PairingInfo = Schema.Struct({
|
||||
urls: Schema.Array(Schema.String),
|
||||
username: Schema.Literal("opencode"),
|
||||
password: Schema.String,
|
||||
})
|
||||
|
||||
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
|
||||
export const AppReconnectService = Rpc.make("AppReconnectService", { success: ServerReadyData })
|
||||
export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", {
|
||||
@@ -59,19 +53,6 @@ export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
|
||||
payload: { value: Schema.Unknown },
|
||||
})
|
||||
export const AppRelaunch = Rpc.make("AppRelaunch")
|
||||
export const AppPairInfo = Rpc.make("AppPairInfo", { success: PairingInfo, error: Schema.String })
|
||||
export const AppPairTailscaleAvailable = Rpc.make("AppPairTailscaleAvailable", { success: Schema.Boolean })
|
||||
export const AppPairTailscaleStatus = Rpc.make("AppPairTailscaleStatus", {
|
||||
success: Schema.NullOr(PairingInfo),
|
||||
error: Schema.String,
|
||||
})
|
||||
export const AppPairOpenTailscale = Rpc.make("AppPairOpenTailscale", { success: PairingInfo, error: Schema.String })
|
||||
export const AppPairDisableTailscale = Rpc.make("AppPairDisableTailscale", { error: Schema.String })
|
||||
export const AppGetKeepScreenActive = Rpc.make("AppGetKeepScreenActive", { success: Schema.Boolean })
|
||||
export const AppSetKeepScreenActive = Rpc.make("AppSetKeepScreenActive", {
|
||||
payload: { enabled: Schema.Boolean },
|
||||
error: Schema.String,
|
||||
})
|
||||
export const AppRpcs = RpcGroup.make(
|
||||
AppAwaitInitialization,
|
||||
AppReconnectService,
|
||||
@@ -88,11 +69,4 @@ export const AppRpcs = RpcGroup.make(
|
||||
AppRecordFatalRendererError,
|
||||
AppSetNativeTranslations,
|
||||
AppRelaunch,
|
||||
AppPairInfo,
|
||||
AppPairTailscaleAvailable,
|
||||
AppPairTailscaleStatus,
|
||||
AppPairOpenTailscale,
|
||||
AppPairDisableTailscale,
|
||||
AppGetKeepScreenActive,
|
||||
AppSetKeepScreenActive,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,14 @@ import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import {
|
||||
HttpMiddleware,
|
||||
HttpPlatform,
|
||||
HttpRouter,
|
||||
HttpServer,
|
||||
HttpServerRequest,
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { isAllowedCorsOrigin } from "./cors"
|
||||
@@ -61,17 +68,15 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
|
||||
}
|
||||
const application = yield* Ref.make(Option.none<App>())
|
||||
const app = dispatch(password, status, application, options.app?.version ?? "unknown", urls, Global.Path.tmp)
|
||||
// Request fibers may continue inbound trace context, but must not inherit the server startup parent.
|
||||
yield* bound.http
|
||||
.serve(
|
||||
(transform ? transform(app) : app).pipe(
|
||||
HttpMiddleware.compression(),
|
||||
dispatch(password, status, application, options.app?.version ?? "unknown", urls, Global.Path.tmp).pipe(
|
||||
HttpMiddleware.cors({ allowedOrigins: (origin) => isAllowedCorsOrigin(origin, options), maxAge: 86_400 }),
|
||||
),
|
||||
errorResponseLogger,
|
||||
)
|
||||
.pipe(Effect.provide(NodeHttpServer.layerHttpServices), withoutParentSpan)
|
||||
.pipe(withoutParentSpan)
|
||||
if (lifecycle)
|
||||
yield* lifecycle.onListen(bound.http.address, shutdown.open.pipe(Effect.asVoid)).pipe(
|
||||
Effect.flatMap((cleanup) =>
|
||||
@@ -105,7 +110,13 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
Effect.provideService(Scope.Scope, applicationScope),
|
||||
)
|
||||
}
|
||||
yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()))
|
||||
const app = Context.get(context, HttpRouter.HttpRouter)
|
||||
.asHttpEffect()
|
||||
.pipe(
|
||||
HttpMiddleware.compression(),
|
||||
Effect.provideService(HttpPlatform.HttpPlatform, Context.get(context, HttpPlatform.HttpPlatform)),
|
||||
)
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
const bus = Context.get(context, Bus.Service)
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live("authenticates API requests behind the frontend transform while allowing browser preflight", () =>
|
||||
it.live("authenticates API and frontend requests while allowing browser preflight", () =>
|
||||
Effect.gen(function* () {
|
||||
const fallback = "fallback".repeat(256)
|
||||
const server = yield* ServerProcess.start<never, never>(
|
||||
@@ -18,13 +18,12 @@ it.live("authenticates API requests behind the frontend transform while allowing
|
||||
},
|
||||
undefined,
|
||||
(api) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
|
||||
return yield* api
|
||||
return HttpServerResponse.raw(fallback, { contentType: "text/plain" })
|
||||
}),
|
||||
api.pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound",
|
||||
() => Effect.succeed(HttpServerResponse.raw(fallback, { contentType: "text/plain" })),
|
||||
),
|
||||
),
|
||||
)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/info", HttpServer.formatAddress(server.address)), {
|
||||
@@ -144,9 +143,9 @@ it.live("authenticates API requests behind the frontend transform while allowing
|
||||
headers: authorization ? { authorization } : undefined,
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("www-authenticate")).toBeNull()
|
||||
expect(yield* Effect.promise(() => response.text())).toBe(method === "HEAD" ? "" : fallback)
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
|
||||
expect(yield* Effect.promise(() => response.text())).toBe("")
|
||||
}),
|
||||
)
|
||||
const response = yield* Effect.promise(() =>
|
||||
|
||||
@@ -362,15 +362,13 @@ $ opencode serve --help
|
||||
|
||||
## pair
|
||||
|
||||
Shows server pairing information, including URLs, credentials, and a QR code. The
|
||||
QR code is an `app.opencode.ai` link carrying the connection details, so a phone
|
||||
camera can open it.
|
||||
Shows server pairing information, including URLs, credentials, and a QR code.
|
||||
|
||||
```bash
|
||||
$ opencode pair
|
||||
```
|
||||
|
||||
Advertise an external URL in the QR code and link.
|
||||
Advertise an external URL in the QR code.
|
||||
|
||||
```bash
|
||||
$ opencode pair --url https://dev.example.com
|
||||
|
||||
@@ -14,18 +14,8 @@ $ opencode pair
|
||||
URLs http://127.0.0.1:49374
|
||||
Username opencode
|
||||
Password ********
|
||||
|
||||
Scan to pair
|
||||
|
||||
█▀▀▀▀▀█ ...
|
||||
|
||||
Link https://app.opencode.ai/connect#...
|
||||
```
|
||||
|
||||
The QR code is an `app.opencode.ai` link carrying the connection details, so a
|
||||
phone camera can open it. For a server on your local network, the page offers to
|
||||
open the server's own web UI instead.
|
||||
|
||||
By default the server runs on port 49374 and listens only on localhost. You can
|
||||
change this config with the `opencode service` command.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user