Compare commits
1 commit
dev
...
daxbox-ser
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07cd7c0f93 |
8 changed files with 157 additions and 7 deletions
|
|
@ -255,6 +255,7 @@ declare global {
|
|||
interface Window {
|
||||
__OPENCODE__?: {
|
||||
deepLinks?: string[]
|
||||
servers?: unknown
|
||||
}
|
||||
api?: {
|
||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { dict as en } from "@/i18n/en"
|
|||
import { dict as zh } from "@/i18n/zh"
|
||||
import { handleNotificationClick } from "@/utils/notification-click"
|
||||
import { authFromToken } from "@/utils/server"
|
||||
import { parseStartupServers } from "@/utils/startup-servers"
|
||||
import pkg from "../package.json"
|
||||
import { ServerConnection } from "./context/server"
|
||||
|
||||
|
|
@ -112,6 +113,14 @@ const getDefaultUrl = () => {
|
|||
return getCurrentUrl()
|
||||
}
|
||||
|
||||
const remoteServersMeta = () => document.querySelector<HTMLMetaElement>('meta[name="opencode-remote-servers"]')?.content
|
||||
|
||||
const getStartupServers = () => [
|
||||
...parseStartupServers(import.meta.env.VITE_OPENCODE_REMOTE_SERVERS),
|
||||
...parseStartupServers(remoteServersMeta()),
|
||||
...parseStartupServers(window.__OPENCODE__?.servers),
|
||||
]
|
||||
|
||||
const clearAuthToken = () => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
if (!params.has("auth_token")) return
|
||||
|
|
@ -171,7 +180,7 @@ if (root instanceof HTMLElement) {
|
|||
<AppInterface
|
||||
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
servers={[server]}
|
||||
servers={[...getStartupServers(), server]}
|
||||
disableHealthCheck
|
||||
/>
|
||||
</AppBaseProviders>
|
||||
|
|
|
|||
1
packages/app/src/env.d.ts
vendored
1
packages/app/src/env.d.ts
vendored
|
|
@ -2,6 +2,7 @@ interface ImportMetaEnv {
|
|||
readonly VITE_OPENCODE_SERVER_HOST: string
|
||||
readonly VITE_OPENCODE_SERVER_PORT: string
|
||||
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
|
||||
readonly VITE_OPENCODE_REMOTE_SERVERS?: string
|
||||
|
||||
readonly VITE_SENTRY_DSN?: string
|
||||
readonly VITE_SENTRY_ENVIRONMENT?: string
|
||||
|
|
|
|||
49
packages/app/src/utils/startup-servers.test.ts
Normal file
49
packages/app/src/utils/startup-servers.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { parseStartupServers } from "./startup-servers"
|
||||
|
||||
describe("parseStartupServers", () => {
|
||||
test("parses comma separated startup servers", () => {
|
||||
expect(parseStartupServers("Dax=http://romulus.example.test:4096, apollo.example.test:4096")).toEqual([
|
||||
{
|
||||
type: "http",
|
||||
displayName: "Dax",
|
||||
http: { url: "http://romulus.example.test:4096" },
|
||||
},
|
||||
{
|
||||
type: "http",
|
||||
http: { url: "http://apollo.example.test:4096" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("parses JSON startup servers with credentials", () => {
|
||||
expect(
|
||||
parseStartupServers(
|
||||
JSON.stringify([
|
||||
{
|
||||
displayName: "Kit box",
|
||||
url: "kit.example.test:4096/",
|
||||
username: "opencode",
|
||||
password: "secret",
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
type: "http",
|
||||
displayName: "Kit box",
|
||||
http: { url: "http://kit.example.test:4096", username: "opencode", password: "secret" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("parses JSON name to URL maps", () => {
|
||||
expect(parseStartupServers('{"Dax":"http://romulus.example.test:4096"}')).toEqual([
|
||||
{
|
||||
type: "http",
|
||||
displayName: "Dax",
|
||||
http: { url: "http://romulus.example.test:4096" },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
62
packages/app/src/utils/startup-servers.ts
Normal file
62
packages/app/src/utils/startup-servers.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { normalizeServerUrl, ServerConnection } from "@/context/server"
|
||||
|
||||
export type StartupServerConfig =
|
||||
| string
|
||||
| {
|
||||
url?: unknown
|
||||
name?: unknown
|
||||
displayName?: unknown
|
||||
username?: unknown
|
||||
password?: unknown
|
||||
}
|
||||
|
||||
export function parseStartupServers(input: unknown): ServerConnection.Http[] {
|
||||
const parsed = typeof input === "string" ? parseServerString(input) : input
|
||||
if (Array.isArray(parsed)) return parsed.flatMap((value) => serverFromConfig(value))
|
||||
if (!isRecord(parsed)) return []
|
||||
return Object.entries(parsed).flatMap(([name, value]) =>
|
||||
typeof value === "string" ? serverFromConfig({ name, url: value }) : serverFromConfig(value),
|
||||
)
|
||||
}
|
||||
|
||||
function parseServerString(input: string): unknown {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) return []
|
||||
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
||||
try {
|
||||
return JSON.parse(trimmed)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return trimmed.split(",").map((item) => {
|
||||
const value = item.trim()
|
||||
const index = value.indexOf("=")
|
||||
if (index <= 0) return value
|
||||
return { name: value.slice(0, index).trim(), url: value.slice(index + 1).trim() }
|
||||
})
|
||||
}
|
||||
|
||||
function serverFromConfig(input: unknown): ServerConnection.Http[] {
|
||||
const url = normalizeServerUrl(typeof input === "string" ? input : (isRecord(input) && stringValue(input.url)) || "")
|
||||
if (!url) return []
|
||||
|
||||
const conn: ServerConnection.Http = { type: "http", http: { url } }
|
||||
if (isRecord(input)) {
|
||||
const displayName = stringValue(input.displayName) ?? stringValue(input.name)
|
||||
if (displayName) conn.displayName = displayName
|
||||
const username = stringValue(input.username)
|
||||
if (username) conn.http.username = username
|
||||
const password = stringValue(input.password)
|
||||
if (password) conn.http.password = password
|
||||
}
|
||||
return [conn]
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
1
packages/desktop/src/renderer/env.d.ts
vendored
1
packages/desktop/src/renderer/env.d.ts
vendored
|
|
@ -5,6 +5,7 @@ declare global {
|
|||
api: ElectronAPI
|
||||
__OPENCODE__?: {
|
||||
deepLinks?: string[]
|
||||
servers?: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,10 +55,24 @@ function notFound() {
|
|||
function embeddedUIResponse(file: string, body: Uint8Array) {
|
||||
const mime = FSUtil.mimeType(file)
|
||||
const headers = new Headers({ "content-type": mime })
|
||||
if (mime.startsWith("text/html")) {
|
||||
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
|
||||
}
|
||||
return HttpServerResponse.raw(body, { headers })
|
||||
if (!mime.startsWith("text/html")) return HttpServerResponse.raw(body, { headers })
|
||||
|
||||
const html = injectRemoteServersMeta(new TextDecoder().decode(body))
|
||||
headers.set("content-security-policy", cspForHtml(html))
|
||||
return HttpServerResponse.raw(new TextEncoder().encode(html), { headers })
|
||||
}
|
||||
|
||||
export function injectRemoteServersMeta(body: string, remoteServers = process.env.OPENCODE_WEB_REMOTE_SERVERS) {
|
||||
const value = remoteServers?.trim()
|
||||
if (!value) return body
|
||||
if (body.includes('name="opencode-remote-servers"')) return body
|
||||
const meta = `<meta name="opencode-remote-servers" content="${escapeHtmlAttribute(value)}">`
|
||||
if (/<head\b[^>]*>/i.test(body)) return body.replace(/<head\b([^>]*)>/i, `<head$1>${meta}`)
|
||||
return `${meta}${body}`
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string) {
|
||||
return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">")
|
||||
}
|
||||
|
||||
export function serveEmbeddedUIEffect(
|
||||
|
|
@ -94,7 +108,7 @@ export function serveUIEffect(
|
|||
const headers = proxyResponseHeaders(response.headers)
|
||||
|
||||
if (response.headers["content-type"]?.includes("text/html")) {
|
||||
const body = yield* response.text
|
||||
const body = injectRemoteServersMeta(yield* response.text)
|
||||
headers.set("Content-Security-Policy", cspForHtml(body))
|
||||
return HttpServerResponse.text(body, { status: response.status, headers })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
|||
import { ServerAuth } from "../../src/server/auth"
|
||||
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
|
||||
import { injectRemoteServersMeta, serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
|
|
@ -27,6 +27,7 @@ const testStateLayer = Layer.effectDiscard(
|
|||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
||||
envUsername: process.env.OPENCODE_SERVER_USERNAME,
|
||||
envWebRemoteServers: process.env.OPENCODE_WEB_REMOTE_SERVERS,
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
|
|
@ -35,6 +36,7 @@ const testStateLayer = Layer.effectDiscard(
|
|||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||
restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
|
||||
restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername)
|
||||
restoreEnv("OPENCODE_WEB_REMOTE_SERVERS", original.envWebRemoteServers)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
@ -184,6 +186,17 @@ function responseText(response: Response) {
|
|||
}
|
||||
|
||||
describe("HttpApi UI fallback", () => {
|
||||
it.live("injects configured remote servers into web UI html", () =>
|
||||
Effect.gen(function* () {
|
||||
const config = '[{"name":"Dax","url":"http://romulus.example.test:4096"}]'
|
||||
const html = injectRemoteServersMeta("<html><head><title>opencode</title></head></html>", config)
|
||||
|
||||
expect(html).toContain('<meta name="opencode-remote-servers"')
|
||||
expect(html).toContain("Dax")
|
||||
expect(html).toContain("http://romulus.example.test:4096")
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("serves the web UI through the HTTP API app", () =>
|
||||
Effect.gen(function* () {
|
||||
let proxiedUrl: string | undefined
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue