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 {
|
interface Window {
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
deepLinks?: string[]
|
deepLinks?: string[]
|
||||||
|
servers?: unknown
|
||||||
}
|
}
|
||||||
api?: {
|
api?: {
|
||||||
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
|
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 { dict as zh } from "@/i18n/zh"
|
||||||
import { handleNotificationClick } from "@/utils/notification-click"
|
import { handleNotificationClick } from "@/utils/notification-click"
|
||||||
import { authFromToken } from "@/utils/server"
|
import { authFromToken } from "@/utils/server"
|
||||||
|
import { parseStartupServers } from "@/utils/startup-servers"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
import { ServerConnection } from "./context/server"
|
import { ServerConnection } from "./context/server"
|
||||||
|
|
||||||
|
|
@ -112,6 +113,14 @@ const getDefaultUrl = () => {
|
||||||
return getCurrentUrl()
|
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 clearAuthToken = () => {
|
||||||
const params = new URLSearchParams(location.search)
|
const params = new URLSearchParams(location.search)
|
||||||
if (!params.has("auth_token")) return
|
if (!params.has("auth_token")) return
|
||||||
|
|
@ -171,7 +180,7 @@ if (root instanceof HTMLElement) {
|
||||||
<AppInterface
|
<AppInterface
|
||||||
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
|
defaultServer={ServerConnection.Key.make(getDefaultUrl())}
|
||||||
canonicalLocalServer={ServerConnection.key(server)}
|
canonicalLocalServer={ServerConnection.key(server)}
|
||||||
servers={[server]}
|
servers={[...getStartupServers(), server]}
|
||||||
disableHealthCheck
|
disableHealthCheck
|
||||||
/>
|
/>
|
||||||
</AppBaseProviders>
|
</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_HOST: string
|
||||||
readonly VITE_OPENCODE_SERVER_PORT: string
|
readonly VITE_OPENCODE_SERVER_PORT: string
|
||||||
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
|
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
|
||||||
|
readonly VITE_OPENCODE_REMOTE_SERVERS?: string
|
||||||
|
|
||||||
readonly VITE_SENTRY_DSN?: string
|
readonly VITE_SENTRY_DSN?: string
|
||||||
readonly VITE_SENTRY_ENVIRONMENT?: 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
|
api: ElectronAPI
|
||||||
__OPENCODE__?: {
|
__OPENCODE__?: {
|
||||||
deepLinks?: string[]
|
deepLinks?: string[]
|
||||||
|
servers?: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,24 @@ function notFound() {
|
||||||
function embeddedUIResponse(file: string, body: Uint8Array) {
|
function embeddedUIResponse(file: string, body: Uint8Array) {
|
||||||
const mime = FSUtil.mimeType(file)
|
const mime = FSUtil.mimeType(file)
|
||||||
const headers = new Headers({ "content-type": mime })
|
const headers = new Headers({ "content-type": mime })
|
||||||
if (mime.startsWith("text/html")) {
|
if (!mime.startsWith("text/html")) return HttpServerResponse.raw(body, { headers })
|
||||||
headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body)))
|
|
||||||
}
|
const html = injectRemoteServersMeta(new TextDecoder().decode(body))
|
||||||
return HttpServerResponse.raw(body, { headers })
|
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(
|
export function serveEmbeddedUIEffect(
|
||||||
|
|
@ -94,7 +108,7 @@ export function serveUIEffect(
|
||||||
const headers = proxyResponseHeaders(response.headers)
|
const headers = proxyResponseHeaders(response.headers)
|
||||||
|
|
||||||
if (response.headers["content-type"]?.includes("text/html")) {
|
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))
|
headers.set("Content-Security-Policy", cspForHtml(body))
|
||||||
return HttpServerResponse.text(body, { status: response.status, headers })
|
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 { ServerAuth } from "../../src/server/auth"
|
||||||
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
|
||||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
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"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
const testStateLayer = Layer.effectDiscard(
|
const testStateLayer = Layer.effectDiscard(
|
||||||
|
|
@ -27,6 +27,7 @@ const testStateLayer = Layer.effectDiscard(
|
||||||
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
|
||||||
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
|
||||||
envUsername: process.env.OPENCODE_SERVER_USERNAME,
|
envUsername: process.env.OPENCODE_SERVER_USERNAME,
|
||||||
|
envWebRemoteServers: process.env.OPENCODE_WEB_REMOTE_SERVERS,
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
yield* Effect.addFinalizer(() =>
|
||||||
|
|
@ -35,6 +36,7 @@ const testStateLayer = Layer.effectDiscard(
|
||||||
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
|
||||||
restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
|
restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
|
||||||
restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername)
|
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", () => {
|
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", () =>
|
it.live("serves the web UI through the HTTP API app", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
let proxiedUrl: string | undefined
|
let proxiedUrl: string | undefined
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue