Compare commits
4 Commits
feat/linux
...
v0.14.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e022a158eb | ||
|
|
9d9a6a79ec | ||
|
|
82a7c95dba | ||
|
|
313a0e579e |
1176
package-lock.json
generated
1176
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,6 @@
|
||||
"google-auth-library": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@esbuild/darwin-arm64": "^0.28.0",
|
||||
"@rollup/rollup-darwin-arm64": "^4.60.2",
|
||||
"baseline-browser-mapping": "^2.9.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,15 +337,6 @@ export interface RemoteServerProbeResponse {
|
||||
errorCode?: string
|
||||
}
|
||||
|
||||
export interface RemoteProxySessionCreateRequest {
|
||||
baseUrl: string
|
||||
skipTlsVerify?: boolean
|
||||
}
|
||||
|
||||
export interface RemoteProxySessionCreateResponse {
|
||||
windowUrl: string
|
||||
}
|
||||
|
||||
export type WorkspaceEventType =
|
||||
| "workspace.created"
|
||||
| "workspace.started"
|
||||
|
||||
@@ -21,7 +21,6 @@ import { launchInBrowser } from "./launcher"
|
||||
import { resolveUi } from "./ui/remote-ui"
|
||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||
import { resolveHttpsOptions } from "./server/tls"
|
||||
import { RemoteProxySessionManager } from "./server/remote-proxy"
|
||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||
import { SpeechService } from "./speech/service"
|
||||
@@ -384,11 +383,6 @@ async function main() {
|
||||
|
||||
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
||||
const pluginChannel = new PluginChannelManager(logger.child({ component: "plugin-channel" }))
|
||||
const remoteProxySessionManager = new RemoteProxySessionManager({
|
||||
authManager,
|
||||
logger: logger.child({ component: "remote-proxy" }),
|
||||
httpsOptions: tlsResolution?.httpsOptions,
|
||||
})
|
||||
const voiceModeManager = new VoiceModeManager({
|
||||
connections: clientConnectionManager,
|
||||
channel: pluginChannel,
|
||||
@@ -428,7 +422,6 @@ async function main() {
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
remoteProxySessionManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: uiResolution.uiDevServerUrl,
|
||||
logger,
|
||||
@@ -454,7 +447,6 @@ async function main() {
|
||||
clientConnectionManager,
|
||||
pluginChannel,
|
||||
voiceModeManager,
|
||||
remoteProxySessionManager,
|
||||
uiStaticDir: uiResolution.uiStaticDir ?? DEFAULT_UI_STATIC_DIR,
|
||||
uiDevServerUrl: undefined,
|
||||
logger,
|
||||
|
||||
@@ -26,7 +26,6 @@ import { registerBackgroundProcessRoutes } from "./routes/background-processes"
|
||||
import { registerWorktreeRoutes } from "./routes/worktrees"
|
||||
import { registerSpeechRoutes } from "./routes/speech"
|
||||
import { registerRemoteServerRoutes } from "./routes/remote-servers"
|
||||
import { registerRemoteProxyRoutes } from "./routes/remote-proxy"
|
||||
import { registerSideCarRoutes } from "./routes/sidecars"
|
||||
import { ServerMeta } from "../api-types"
|
||||
import { InstanceStore } from "../storage/instance-store"
|
||||
@@ -39,7 +38,6 @@ import { ClientConnectionManager } from "../clients/connection-manager"
|
||||
import { PluginChannelManager } from "../plugins/channel"
|
||||
import { VoiceModeManager } from "../plugins/voice-mode"
|
||||
import type { SideCarManager } from "../sidecars/manager"
|
||||
import type { RemoteProxySessionManager } from "./remote-proxy"
|
||||
|
||||
interface HttpServerDeps {
|
||||
bindHost: string
|
||||
@@ -60,7 +58,6 @@ interface HttpServerDeps {
|
||||
clientConnectionManager: ClientConnectionManager
|
||||
pluginChannel: PluginChannelManager
|
||||
voiceModeManager: VoiceModeManager
|
||||
remoteProxySessionManager: RemoteProxySessionManager
|
||||
uiStaticDir: string
|
||||
uiDevServerUrl?: string
|
||||
logger: Logger
|
||||
@@ -277,7 +274,6 @@ export function createHttpServer(deps: HttpServerDeps) {
|
||||
workspaceManager: deps.workspaceManager,
|
||||
})
|
||||
registerRemoteServerRoutes(app, { logger: apiLogger })
|
||||
registerRemoteProxyRoutes(app, { logger: proxyLogger, sessionManager: deps.remoteProxySessionManager })
|
||||
registerSpeechRoutes(app, { speechService: deps.speechService })
|
||||
registerSideCarRoutes(app, { sidecarManager: deps.sidecarManager })
|
||||
registerSideCarProxyRoutes(app, { sidecarManager: deps.sidecarManager, logger: proxyLogger })
|
||||
|
||||
@@ -1,533 +0,0 @@
|
||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
||||
import { randomBytes, randomUUID } from "crypto"
|
||||
import { Readable } from "stream"
|
||||
import { Agent, fetch } from "undici"
|
||||
import type { AuthManager } from "../auth/manager"
|
||||
import type { Logger } from "../logger"
|
||||
|
||||
const LOOPBACK_HOST = "127.0.0.1"
|
||||
const BOOTSTRAP_PAGE_PATH = "/__codenomad/auth/token"
|
||||
const BOOTSTRAP_EXCHANGE_PATH = "/__codenomad/api/auth/token"
|
||||
const SESSION_IDLE_TTL_MS = 30 * 60_000
|
||||
|
||||
interface RemoteProxySession {
|
||||
id: string
|
||||
targetBaseUrl: URL
|
||||
skipTlsVerify: boolean
|
||||
localBaseUrl: URL
|
||||
entryUrl: URL
|
||||
bootstrapUrl: string
|
||||
activated: boolean
|
||||
cookiePrefix: string
|
||||
app: FastifyInstance
|
||||
dispatcher?: Agent
|
||||
createdAt: number
|
||||
lastAccessAt: number
|
||||
}
|
||||
|
||||
export interface RemoteProxySessionManagerOptions {
|
||||
authManager: AuthManager
|
||||
logger: Logger
|
||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||
}
|
||||
|
||||
export class RemoteProxySessionManager {
|
||||
private readonly sessions = new Map<string, RemoteProxySession>()
|
||||
private readonly cleanupTimer: NodeJS.Timeout
|
||||
|
||||
constructor(private readonly options: RemoteProxySessionManagerOptions) {
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
void this.cleanupExpiredSessions()
|
||||
}, 60_000)
|
||||
this.cleanupTimer.unref()
|
||||
}
|
||||
|
||||
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<string> {
|
||||
if (!this.options.httpsOptions) {
|
||||
throw new Error("Local HTTPS is required for remote proxy sessions")
|
||||
}
|
||||
|
||||
const targetBaseUrl = normalizeBaseUrl(baseUrl)
|
||||
const token = this.options.authManager.issueBootstrapToken()
|
||||
if (!token) {
|
||||
throw new Error("Bootstrap token generation is unavailable")
|
||||
}
|
||||
|
||||
const sessionId = randomUUID()
|
||||
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
||||
const app = Fastify({ logger: false, https: this.options.httpsOptions })
|
||||
let session: RemoteProxySession | null = null
|
||||
|
||||
app.removeAllContentTypeParsers()
|
||||
app.addContentTypeParser("*", (req, body, done) => done(null, body))
|
||||
|
||||
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
|
||||
if (!this.options.authManager.isLoopbackRequest(request)) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
reply.header("Cache-Control", "no-store")
|
||||
reply.header("Pragma", "no-cache")
|
||||
reply.header("Expires", "0")
|
||||
reply.type("text/html").send(buildBootstrapPageHtml())
|
||||
})
|
||||
|
||||
app.post(BOOTSTRAP_EXCHANGE_PATH, async (request, reply) => {
|
||||
if (!this.options.authManager.isLoopbackRequest(request)) {
|
||||
reply.code(404).send({ error: "Not found" })
|
||||
return
|
||||
}
|
||||
|
||||
const body = parseTokenBody(request.body)
|
||||
if (!this.options.authManager.consumeBootstrapToken(body.token)) {
|
||||
reply.code(401).send({ error: "Invalid token" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||
return
|
||||
}
|
||||
|
||||
session.activated = true
|
||||
session.lastAccessAt = Date.now()
|
||||
reply.send({ ok: true })
|
||||
})
|
||||
|
||||
app.all("/*", async (request, reply) => {
|
||||
if (!session) {
|
||||
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!session.activated) {
|
||||
reply.code(403).send({ error: "Remote proxy session is not activated" })
|
||||
return
|
||||
}
|
||||
|
||||
session.lastAccessAt = Date.now()
|
||||
await proxyRequest({ request, reply, session, logger: this.options.logger })
|
||||
})
|
||||
|
||||
app.setNotFoundHandler(async (request, reply) => {
|
||||
if (!session) {
|
||||
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||
return
|
||||
}
|
||||
|
||||
if (!session.activated) {
|
||||
reply.code(403).send({ error: "Remote proxy session is not activated" })
|
||||
return
|
||||
}
|
||||
|
||||
session.lastAccessAt = Date.now()
|
||||
await proxyRequest({ request, reply, session, logger: this.options.logger })
|
||||
})
|
||||
|
||||
const addressInfo = await app.listen({ host: LOOPBACK_HOST, port: 0 })
|
||||
const address = new URL(addressInfo)
|
||||
const localBaseUrl = new URL(`https://${LOOPBACK_HOST}:${address.port}`)
|
||||
const entryUrl = new URL(targetBaseUrl.pathname || "/", localBaseUrl)
|
||||
const returnTo = buildReturnToTarget(entryUrl)
|
||||
|
||||
session = {
|
||||
id: sessionId,
|
||||
targetBaseUrl,
|
||||
skipTlsVerify,
|
||||
localBaseUrl,
|
||||
entryUrl,
|
||||
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(token)}`,
|
||||
activated: false,
|
||||
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
|
||||
app,
|
||||
dispatcher,
|
||||
createdAt: Date.now(),
|
||||
lastAccessAt: Date.now(),
|
||||
}
|
||||
|
||||
this.sessions.set(sessionId, session)
|
||||
this.options.logger.info(
|
||||
{ sessionId, targetBaseUrl: targetBaseUrl.toString(), localBaseUrl: localBaseUrl.toString() },
|
||||
"Created remote proxy session",
|
||||
)
|
||||
|
||||
return session.bootstrapUrl
|
||||
}
|
||||
|
||||
private async cleanupExpiredSessions() {
|
||||
const now = Date.now()
|
||||
for (const session of Array.from(this.sessions.values())) {
|
||||
if (now - session.lastAccessAt <= SESSION_IDLE_TTL_MS) {
|
||||
continue
|
||||
}
|
||||
await this.disposeSession(session.id)
|
||||
}
|
||||
}
|
||||
|
||||
private async disposeSession(sessionId: string) {
|
||||
const session = this.sessions.get(sessionId)
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
|
||||
this.sessions.delete(sessionId)
|
||||
session.dispatcher?.close().catch(() => {})
|
||||
await session.app.close().catch(() => {})
|
||||
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(input: string): URL {
|
||||
const parsed = new URL(input.trim())
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error("Server URL must use http:// or https://")
|
||||
}
|
||||
|
||||
parsed.hash = ""
|
||||
parsed.search = ""
|
||||
parsed.pathname = parsed.pathname === "/" ? "/" : parsed.pathname.replace(/\/+$/, "") || "/"
|
||||
return parsed
|
||||
}
|
||||
|
||||
function buildReturnToTarget(entryUrl: URL): string {
|
||||
const query = entryUrl.search ? entryUrl.search : ""
|
||||
return `${entryUrl.pathname || "/"}${query}`
|
||||
}
|
||||
|
||||
function buildBootstrapPageHtml(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CodeNomad</title>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; background: #0b0b0f; color: #fff; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; }
|
||||
.card { width: 420px; max-width: calc(100vw - 32px); background: #14141c; border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 24px; }
|
||||
h1 { font-size: 18px; margin: 0 0 12px; }
|
||||
p { margin: 0; color: rgba(255,255,255,0.7); font-size: 13px; line-height: 1.4; }
|
||||
.error { margin-top: 12px; color: #ff6b6b; font-size: 13px; display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Connecting...</h1>
|
||||
<p>Finalizing local authentication.</p>
|
||||
<div id="error" class="error"></div>
|
||||
</div>
|
||||
<script>
|
||||
const token = decodeURIComponent((location.hash || "").replace(/^#/, "").trim())
|
||||
const params = new URLSearchParams(location.search)
|
||||
const returnTo = sanitizeReturnTo(params.get("returnTo"))
|
||||
const errorEl = document.getElementById("error")
|
||||
|
||||
function sanitizeReturnTo(value) {
|
||||
if (!value || typeof value !== "string") return "/"
|
||||
if (!value.startsWith("/")) return "/"
|
||||
if (value.startsWith("//")) return "/"
|
||||
return value
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
errorEl.textContent = message
|
||||
errorEl.style.display = "block"
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!token) {
|
||||
showError("Missing bootstrap token.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("${BOOTSTRAP_EXCHANGE_PATH}", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
credentials: "include",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
let message = ""
|
||||
try {
|
||||
const json = await res.json()
|
||||
message = json && json.error ? String(json.error) : ""
|
||||
} catch {
|
||||
message = ""
|
||||
}
|
||||
showError(message || "Token exchange failed (" + res.status + ")")
|
||||
return
|
||||
}
|
||||
|
||||
window.location.replace(returnTo)
|
||||
} catch (error) {
|
||||
showError(error && error.message ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
run()
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function parseTokenBody(body: unknown): { token: string } {
|
||||
const value = normalizeJsonBody(body) as { token?: unknown } | null | undefined
|
||||
const token = typeof value?.token === "string" ? value.token.trim() : ""
|
||||
if (!token) {
|
||||
throw new Error("Missing bootstrap token")
|
||||
}
|
||||
return { token }
|
||||
}
|
||||
|
||||
function normalizeJsonBody(body: unknown): unknown {
|
||||
if (Buffer.isBuffer(body)) {
|
||||
return JSON.parse(body.toString("utf-8"))
|
||||
}
|
||||
if (typeof body === "string") {
|
||||
return JSON.parse(body)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
function toRequestBody(body: unknown): any {
|
||||
if (body == null) {
|
||||
return undefined
|
||||
}
|
||||
if (Buffer.isBuffer(body) || typeof body === "string" || body instanceof Uint8Array) {
|
||||
return body
|
||||
}
|
||||
return JSON.stringify(body)
|
||||
}
|
||||
|
||||
async function proxyRequest(args: {
|
||||
request: FastifyRequest
|
||||
reply: FastifyReply
|
||||
session: RemoteProxySession
|
||||
logger: Logger
|
||||
}) {
|
||||
const { request, reply, session, logger } = args
|
||||
const upstreamUrl = buildUpstreamUrl(session.targetBaseUrl, request.raw.url ?? request.url)
|
||||
const headers = filterRequestHeaders(request.headers, session)
|
||||
|
||||
const init: any = {
|
||||
method: request.method,
|
||||
headers,
|
||||
dispatcher: session.dispatcher,
|
||||
redirect: "manual",
|
||||
}
|
||||
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
const body = toRequestBody(request.body)
|
||||
if (body !== undefined) {
|
||||
init.body = body
|
||||
init.duplex = "half"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(upstreamUrl, init as any)
|
||||
reply.code(response.status)
|
||||
applyResponseHeaders(reply, response, session)
|
||||
|
||||
if (!response.body || request.method === "HEAD") {
|
||||
reply.send()
|
||||
return
|
||||
}
|
||||
|
||||
reply.send(Readable.fromWeb(response.body as any))
|
||||
} catch (error) {
|
||||
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
|
||||
if (!reply.sent) {
|
||||
reply.code(502).send({ error: "Remote proxy request failed" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpstreamUrl(baseUrl: URL, rawUrl: string): string {
|
||||
const parsed = new URL(rawUrl, "https://localhost")
|
||||
const url = new URL(baseUrl.toString())
|
||||
url.pathname = rewriteRequestPath(baseUrl, parsed.pathname)
|
||||
url.search = stripInternalQuery(parsed.search)
|
||||
url.hash = ""
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function rewriteRequestPath(baseUrl: URL, requestPath: string): string {
|
||||
const basePath = normalizedBasePath(baseUrl)
|
||||
if (basePath === "/") {
|
||||
return requestPath
|
||||
}
|
||||
|
||||
if (requestPath === "/") {
|
||||
return basePath
|
||||
}
|
||||
|
||||
if (pathHasBasePrefix(basePath, requestPath)) {
|
||||
return requestPath
|
||||
}
|
||||
|
||||
return `${basePath}${requestPath}`
|
||||
}
|
||||
|
||||
function normalizedBasePath(baseUrl: URL): string {
|
||||
return baseUrl.pathname || "/"
|
||||
}
|
||||
|
||||
function pathHasBasePrefix(basePath: string, requestPath: string): boolean {
|
||||
return requestPath === basePath || requestPath.startsWith(`${basePath}/`)
|
||||
}
|
||||
|
||||
function stripInternalQuery(search: string): string {
|
||||
if (!search || search === "?") {
|
||||
return ""
|
||||
}
|
||||
return search
|
||||
}
|
||||
|
||||
function filterRequestHeaders(
|
||||
headers: FastifyRequest["headers"],
|
||||
session: RemoteProxySession,
|
||||
): Record<string, string> {
|
||||
const next: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(headers ?? {})) {
|
||||
if (!value) continue
|
||||
const lower = key.toLowerCase()
|
||||
if (isHopByHopHeader(lower) || lower === "host" || lower === "content-length") {
|
||||
continue
|
||||
}
|
||||
if (lower === "origin") {
|
||||
next[key] = session.targetBaseUrl.origin
|
||||
continue
|
||||
}
|
||||
if (lower === "referer") {
|
||||
const rewritten = rewriteRefererHeader(Array.isArray(value) ? value[0] : value, session.targetBaseUrl)
|
||||
if (rewritten) {
|
||||
next[key] = rewritten
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (lower === "cookie") {
|
||||
const rewritten = rewriteRequestCookieHeader(Array.isArray(value) ? value.join("; ") : value, session.cookiePrefix)
|
||||
if (rewritten) {
|
||||
next[key] = rewritten
|
||||
}
|
||||
continue
|
||||
}
|
||||
next[key] = Array.isArray(value) ? value.join(",") : value
|
||||
}
|
||||
|
||||
next.host = session.targetBaseUrl.port ? `${session.targetBaseUrl.hostname}:${session.targetBaseUrl.port}` : session.targetBaseUrl.hostname
|
||||
if (!next.origin) {
|
||||
next.origin = session.targetBaseUrl.origin
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function rewriteRefererHeader(referer: string | undefined, targetBaseUrl: URL): string | null {
|
||||
if (!referer) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(referer)
|
||||
const rewritten = new URL(targetBaseUrl.toString())
|
||||
rewritten.pathname = rewriteRequestPath(targetBaseUrl, parsed.pathname)
|
||||
rewritten.search = parsed.search
|
||||
rewritten.hash = parsed.hash
|
||||
return rewritten.toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function applyResponseHeaders(reply: FastifyReply, response: any, session: RemoteProxySession) {
|
||||
const setCookie = (response.headers as any).getSetCookie?.() as string[] | undefined
|
||||
if (Array.isArray(setCookie)) {
|
||||
for (const cookie of setCookie) {
|
||||
reply.header("set-cookie", rewriteSetCookie(cookie, session.cookiePrefix))
|
||||
}
|
||||
}
|
||||
|
||||
response.headers.forEach((value: string, key: string) => {
|
||||
const lower = key.toLowerCase()
|
||||
if (isHopByHopHeader(lower) || lower === "set-cookie") {
|
||||
return
|
||||
}
|
||||
|
||||
if (lower === "location") {
|
||||
reply.header(key, rewriteLocation(value, session.targetBaseUrl, session.localBaseUrl))
|
||||
return
|
||||
}
|
||||
|
||||
reply.header(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
|
||||
const parts = cookie.split(";").map((part) => part.trim())
|
||||
const first = parts.shift() ?? ""
|
||||
const separator = first.indexOf("=")
|
||||
if (separator <= 0) {
|
||||
return cookie
|
||||
}
|
||||
|
||||
const name = first.slice(0, separator).trim()
|
||||
const value = first.slice(separator + 1)
|
||||
const rewritten = [`${cookiePrefix}${name}=${value}`]
|
||||
for (const part of parts) {
|
||||
if (part.slice(0, 7).toLowerCase().startsWith("domain=")) {
|
||||
continue
|
||||
}
|
||||
rewritten.push(part)
|
||||
}
|
||||
return rewritten.join("; ")
|
||||
}
|
||||
|
||||
function rewriteRequestCookieHeader(cookieHeader: string, cookiePrefix: string): string {
|
||||
const next: string[] = []
|
||||
for (const rawPart of cookieHeader.split(";")) {
|
||||
const part = rawPart.trim()
|
||||
if (!part) continue
|
||||
const separator = part.indexOf("=")
|
||||
if (separator <= 0) continue
|
||||
const name = part.slice(0, separator).trim()
|
||||
const value = part.slice(separator + 1)
|
||||
if (!name.startsWith(cookiePrefix)) {
|
||||
continue
|
||||
}
|
||||
next.push(`${name.slice(cookiePrefix.length)}=${value}`)
|
||||
}
|
||||
return next.join("; ")
|
||||
}
|
||||
|
||||
function rewriteLocation(location: string, targetBaseUrl: URL, localBaseUrl: URL): string {
|
||||
try {
|
||||
const parsed = new URL(location, targetBaseUrl)
|
||||
if (parsed.origin !== targetBaseUrl.origin) {
|
||||
return location
|
||||
}
|
||||
|
||||
const rewritten = new URL(localBaseUrl.toString())
|
||||
rewritten.pathname = parsed.pathname
|
||||
rewritten.search = parsed.search
|
||||
rewritten.hash = parsed.hash
|
||||
return rewritten.toString()
|
||||
} catch {
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
function isHopByHopHeader(name: string): boolean {
|
||||
return new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
]).has(name)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { RemoteProxySessionCreateResponse } from "../../api-types"
|
||||
import type { Logger } from "../../logger"
|
||||
import type { RemoteProxySessionManager } from "../remote-proxy"
|
||||
|
||||
interface RouteDeps {
|
||||
logger: Logger
|
||||
sessionManager: RemoteProxySessionManager
|
||||
}
|
||||
|
||||
const CreateSessionSchema = z.object({
|
||||
baseUrl: z.string().min(1),
|
||||
skipTlsVerify: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||
app.post("/api/remote-proxy/sessions", async (request, reply): Promise<RemoteProxySessionCreateResponse | { error: string }> => {
|
||||
try {
|
||||
const body = CreateSessionSchema.parse(request.body ?? {})
|
||||
const windowUrl = await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||
return { windowUrl }
|
||||
} catch (error) {
|
||||
deps.logger.warn({ err: error }, "Failed to create remote proxy session")
|
||||
reply.code(400)
|
||||
return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" }
|
||||
}
|
||||
})
|
||||
}
|
||||
515
packages/tauri-app/Cargo.lock
generated
515
packages/tauri-app/Cargo.lock
generated
@@ -47,15 +47,6 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -222,105 +213,6 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.39.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-core"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-server"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"fs-err",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -516,8 +408,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -554,12 +444,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -572,34 +456,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codenomad-tauri"
|
||||
version = "0.14.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"axum-server",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"keepawake",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"rand 0.8.5",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -610,9 +477,7 @@ dependencies = [
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"url",
|
||||
"webkit2gtk",
|
||||
"which",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
@@ -1104,15 +969,6 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1283,22 +1139,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futf"
|
||||
version = "0.1.5"
|
||||
@@ -1539,10 +1379,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1552,11 +1390,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1738,25 +1574,6 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http",
|
||||
"indexmap 2.13.0",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -1872,12 +1689,6 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.8.1"
|
||||
@@ -1888,11 +1699,9 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"pin-utils",
|
||||
@@ -1901,23 +1710,6 @@ dependencies = [
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -2207,16 +1999,6 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.91"
|
||||
@@ -2375,12 +2157,6 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -2441,12 +2217,6 @@ version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||
|
||||
[[package]]
|
||||
name = "matchit"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@@ -3225,61 +2995,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -3497,50 +3212,6 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.2"
|
||||
@@ -3571,7 +3242,7 @@ dependencies = [
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.5.0",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
@@ -3599,20 +3270,6 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"getrandom 0.2.17",
|
||||
"libc",
|
||||
"untrusted",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -3654,53 +3311,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -3892,17 +3502,6 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_path_to_error"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
@@ -3932,18 +3531,6 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.18.0"
|
||||
@@ -4205,12 +3792,6 @@ version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "swift-rs"
|
||||
version = "1.0.7"
|
||||
@@ -4362,7 +3943,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest 0.13.2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -4786,21 +4367,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.50.0"
|
||||
@@ -4812,31 +4378,9 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
@@ -4968,7 +4512,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5007,7 +4550,6 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -5149,12 +4691,6 @@ version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
@@ -5366,19 +4902,6 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.5.0"
|
||||
@@ -5414,16 +4937,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.3"
|
||||
@@ -5480,15 +4993,6 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -5782,15 +5286,6 @@ dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
@@ -6432,12 +5927,6 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
version = "0.2.3"
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"build": "tauri build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.4",
|
||||
"@tauri-apps/cli-darwin-arm64": "^2.9.4"
|
||||
"@tauri-apps/cli": "^2.9.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,11 @@ tauri = { version = "2.5.2", features = [ "devtools"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
axum = "0.7"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
futures-util = "0.3"
|
||||
rustls = { version = "0.23", features = ["ring"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
|
||||
rand = "0.8"
|
||||
regex = "1"
|
||||
once_cell = "1"
|
||||
parking_lot = "0.12"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
|
||||
which = "4"
|
||||
libc = "0.2"
|
||||
keepawake = "0.6"
|
||||
@@ -37,7 +28,4 @@ url = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0.2"
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_Shell", "Win32_Security", "Win32_System_JobObjects"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,248 +0,0 @@
|
||||
use base64::Engine;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||
const TLS_DIR_NAME: &str = "tls";
|
||||
const CA_CERT_FILE: &str = "ca-cert.pem";
|
||||
const SERVER_CERT_FILE: &str = "server-cert.pem";
|
||||
const SERVER_KEY_FILE: &str = "server-key.pem";
|
||||
const TRUSTED_MARKER: &str = "server-ca.trusted";
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
|
||||
/// Holds the PEM-encoded certificate/key pair used by the local HTTPS proxy,
|
||||
/// plus the CA certificate DER used for trust-store installation.
|
||||
pub struct LocalCert {
|
||||
pub cert_pem: String,
|
||||
pub key_pem: String,
|
||||
pub ca_cert_der: Vec<u8>,
|
||||
}
|
||||
|
||||
struct TlsAssetPaths {
|
||||
cert_path: PathBuf,
|
||||
key_path: PathBuf,
|
||||
trust_path: PathBuf,
|
||||
append_ca_to_cert: bool,
|
||||
}
|
||||
|
||||
/// Loads the TLS assets already managed by `packages/server`.
|
||||
pub fn ensure_local_cert() -> Result<LocalCert, String> {
|
||||
let assets = resolve_tls_asset_paths()?;
|
||||
let mut cert_pem = read_pem_file(&assets.cert_path)?;
|
||||
let key_pem = read_pem_file(&assets.key_path)?;
|
||||
let trust_pem = read_pem_file(&assets.trust_path)?;
|
||||
|
||||
if assets.append_ca_to_cert {
|
||||
cert_pem = format!("{}\n{}\n", cert_pem.trim(), trust_pem.trim());
|
||||
}
|
||||
|
||||
let ca_cert_der = pem_to_der(&trust_pem)?;
|
||||
|
||||
Ok(LocalCert {
|
||||
cert_pem,
|
||||
key_pem,
|
||||
ca_cert_der,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_pem_file(path: &Path) -> Result<String, String> {
|
||||
fs::read_to_string(path).map_err(|e| format!("Failed to read {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
fn server_tls_dir() -> Result<PathBuf, String> {
|
||||
Ok(resolve_server_config_base_dir()?.join(TLS_DIR_NAME))
|
||||
}
|
||||
|
||||
fn resolve_tls_asset_paths() -> Result<TlsAssetPaths, String> {
|
||||
let tls_key_path = env::var("CLI_TLS_KEY")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| resolve_path_like_server(&value))
|
||||
.transpose()?;
|
||||
let tls_cert_path = env::var("CLI_TLS_CERT")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| resolve_path_like_server(&value))
|
||||
.transpose()?;
|
||||
let tls_ca_path = env::var("CLI_TLS_CA")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(|value| resolve_path_like_server(&value))
|
||||
.transpose()?;
|
||||
|
||||
match (tls_key_path, tls_cert_path) {
|
||||
(Some(key_path), Some(cert_path)) => {
|
||||
let append_ca_to_cert = tls_ca_path.is_some();
|
||||
let trust_path = tls_ca_path.unwrap_or_else(|| cert_path.clone());
|
||||
Ok(TlsAssetPaths {
|
||||
cert_path,
|
||||
key_path,
|
||||
trust_path,
|
||||
append_ca_to_cert,
|
||||
})
|
||||
}
|
||||
(Some(_), None) | (None, Some(_)) => Err(
|
||||
"CLI_TLS_KEY and CLI_TLS_CERT must both be set when using custom TLS files"
|
||||
.to_string(),
|
||||
),
|
||||
(None, None) => {
|
||||
let tls_dir = server_tls_dir()?;
|
||||
Ok(TlsAssetPaths {
|
||||
cert_path: tls_dir.join(SERVER_CERT_FILE),
|
||||
key_path: tls_dir.join(SERVER_KEY_FILE),
|
||||
trust_path: tls_dir.join(CA_CERT_FILE),
|
||||
append_ca_to_cert: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_server_config_base_dir() -> Result<PathBuf, String> {
|
||||
let raw = env::var("CLI_CONFIG")
|
||||
.ok()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string());
|
||||
let expanded = resolve_path_like_server(&raw)?;
|
||||
let lower = raw.trim().to_lowercase();
|
||||
|
||||
if lower.ends_with(".yaml") || lower.ends_with(".yml") || lower.ends_with(".json") {
|
||||
return expanded
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.ok_or_else(|| format!("Failed to determine config base dir from {}", expanded.display()));
|
||||
}
|
||||
|
||||
Ok(expanded)
|
||||
}
|
||||
|
||||
fn resolve_path_like_server(path: &str) -> Result<PathBuf, String> {
|
||||
if path.starts_with("~/") {
|
||||
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
|
||||
let home = home.ok_or_else(|| "Cannot determine home directory".to_string())?;
|
||||
return Ok(home.join(path.trim_start_matches("~/")));
|
||||
}
|
||||
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_absolute() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let cwd = env::current_dir().map_err(|e| format!("Failed to read current dir: {e}"))?;
|
||||
Ok(cwd.join(path))
|
||||
}
|
||||
|
||||
fn trusted_marker_path() -> Result<PathBuf, String> {
|
||||
let base = dirs::data_local_dir()
|
||||
.ok_or_else(|| "Cannot determine local app data directory".to_string())?;
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return Ok(base.join(WINDOWS_APP_USER_MODEL_ID).join(TRUSTED_MARKER));
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Ok(base.join("codenomad").join(TRUSTED_MARKER))
|
||||
}
|
||||
}
|
||||
|
||||
fn trusted_marker_value(cert_der: &[u8]) -> String {
|
||||
cert_der.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn has_matching_trusted_marker(cert_der: &[u8]) -> bool {
|
||||
trusted_marker_path()
|
||||
.ok()
|
||||
.and_then(|path| fs::read_to_string(path).ok())
|
||||
.map(|value| value.trim() == trusted_marker_value(cert_der))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn write_trusted_marker(cert_der: &[u8]) -> Result<(), String> {
|
||||
let path = trusted_marker_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create trust state dir {}: {e}", parent.display()))?;
|
||||
}
|
||||
fs::write(path, trusted_marker_value(cert_der))
|
||||
.map_err(|e| format!("Failed to write trust marker: {e}"))
|
||||
}
|
||||
|
||||
/// Adds the DER-encoded CA certificate to the Windows `CurrentUser\Root` store.
|
||||
/// This will show a one-time Windows security confirmation dialog when needed.
|
||||
#[cfg(windows)]
|
||||
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
|
||||
use windows_sys::Win32::Security::Cryptography::{
|
||||
CertAddEncodedCertificateToStore, CertCloseStore, CertOpenSystemStoreW,
|
||||
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
|
||||
};
|
||||
|
||||
if has_matching_trusted_marker(cert_der) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let store_name: Vec<u16> = "Root\0".encode_utf16().collect();
|
||||
|
||||
unsafe {
|
||||
let store = CertOpenSystemStoreW(0, store_name.as_ptr());
|
||||
if store.is_null() {
|
||||
return Err("Failed to open CurrentUser\\Root certificate store".into());
|
||||
}
|
||||
|
||||
let encoding = X509_ASN_ENCODING | PKCS_7_ASN_ENCODING;
|
||||
let result = CertAddEncodedCertificateToStore(
|
||||
store,
|
||||
encoding,
|
||||
cert_der.as_ptr(),
|
||||
cert_der.len() as u32,
|
||||
CERT_STORE_ADD_REPLACE_EXISTING,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
|
||||
CertCloseStore(store, 0);
|
||||
|
||||
if result == 0 {
|
||||
return Err(
|
||||
"Failed to add certificate to trust store. The user may have declined the security dialog."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
write_trusted_marker(cert_der)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn trust_cert_in_store(_cert_der: &[u8]) -> Result<(), String> {
|
||||
// Non-Windows platforms use native webview-specific handling instead of OS trust-store writes.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pem_to_der(pem: &str) -> Result<Vec<u8>, String> {
|
||||
let mut body = String::new();
|
||||
let mut in_block = false;
|
||||
|
||||
for line in pem.lines() {
|
||||
if line.starts_with("-----BEGIN CERTIFICATE-----") {
|
||||
in_block = true;
|
||||
continue;
|
||||
}
|
||||
if line.starts_with("-----END CERTIFICATE-----") {
|
||||
break;
|
||||
}
|
||||
if in_block {
|
||||
body.push_str(line.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if body.is_empty() {
|
||||
return Err("No certificate found in PEM file".to_string());
|
||||
}
|
||||
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(body)
|
||||
.map_err(|e| format!("Failed to decode certificate PEM: {e}"))
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
use crate::AppState;
|
||||
use tauri::{AppHandle, Manager, WebviewWindow};
|
||||
use url::Url;
|
||||
use webkit2gtk::{WebContextExt, WebView, WebViewExt};
|
||||
|
||||
pub fn should_bootstrap_tls_navigation(target_url: &Url, skip_tls_verify: bool) -> bool {
|
||||
skip_tls_verify && target_url.scheme() == "https"
|
||||
}
|
||||
|
||||
pub fn ensure_remote_window_tls_handler(
|
||||
window: &WebviewWindow,
|
||||
app_handle: &AppHandle,
|
||||
window_label: &str,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let state = app_handle.state::<AppState>();
|
||||
let mut handlers = state
|
||||
.remote_tls_handlers
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?;
|
||||
if !handlers.insert(window_label.to_string()) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let app_handle = app_handle.clone();
|
||||
let window_label = window_label.to_string();
|
||||
window
|
||||
.with_webview(move |platform_webview| {
|
||||
let webview = platform_webview.inner();
|
||||
let app_handle = app_handle.clone();
|
||||
let window_label = window_label.clone();
|
||||
webview.connect_load_failed_with_tls_errors(move |view, failing_uri, certificate, _| {
|
||||
allow_remote_tls_certificate(
|
||||
&app_handle,
|
||||
&window_label,
|
||||
view,
|
||||
failing_uri,
|
||||
certificate,
|
||||
)
|
||||
});
|
||||
})
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn allow_remote_tls_certificate(
|
||||
app_handle: &AppHandle,
|
||||
window_label: &str,
|
||||
view: &WebView,
|
||||
failing_uri: &str,
|
||||
certificate: &webkit2gtk::gio::TlsCertificate,
|
||||
) -> bool {
|
||||
let Ok(parsed_uri) = Url::parse(failing_uri) else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = parsed_uri.host_str() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let state = app_handle.state::<AppState>();
|
||||
let skip_tls_verify = state
|
||||
.remote_skip_tls_verify
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|values| values.get(window_label).copied())
|
||||
.unwrap_or(false);
|
||||
if !skip_tls_verify {
|
||||
return false;
|
||||
}
|
||||
|
||||
let expected_origin = state
|
||||
.remote_origins
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|origins| origins.get(window_label).cloned());
|
||||
let parsed_origin = parsed_uri.origin().ascii_serialization();
|
||||
if expected_origin.as_deref() != Some(parsed_origin.as_str()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(context) = view.context() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
context.allow_tls_certificate_for_host(certificate, host);
|
||||
view.load_uri(failing_uri);
|
||||
true
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod cert_manager;
|
||||
mod cli_manager;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_tls;
|
||||
|
||||
use cli_manager::{CliProcessManager, CliStatus};
|
||||
use keepawake::KeepAwake;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -49,8 +45,6 @@ pub struct AppState {
|
||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||
pub zoom_level: Mutex<f64>,
|
||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
|
||||
pub remote_tls_handlers: Mutex<HashSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -59,8 +53,6 @@ struct RemoteWindowPayload {
|
||||
id: String,
|
||||
name: String,
|
||||
base_url: String,
|
||||
entry_url: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
skip_tls_verify: bool,
|
||||
}
|
||||
|
||||
@@ -127,7 +119,7 @@ fn is_dev_mode() -> bool {
|
||||
|
||||
fn should_allow_internal(url: &Url) -> bool {
|
||||
match url.scheme() {
|
||||
"tauri" | "asset" | "file" | "about" => true,
|
||||
"tauri" | "asset" | "file" => true,
|
||||
// On Windows/WebView2, Tauri serves the app assets from `tauri.localhost`.
|
||||
// This must be treated as an internal origin or the navigation guard will
|
||||
// redirect it to the system browser and the app will appear blank.
|
||||
@@ -175,40 +167,25 @@ fn intercept_navigation<R: Runtime>(webview: &Webview<R>, url: &Url) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn open_remote_window_impl(
|
||||
app: AppHandle,
|
||||
payload: RemoteWindowPayload,
|
||||
) -> Result<(), String> {
|
||||
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
|
||||
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
|
||||
#[tauri::command]
|
||||
fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||
if payload.skip_tls_verify && payload.base_url.starts_with("https://") {
|
||||
return Err(
|
||||
"Tauri cannot bypass self-signed HTTPS certificates automatically yet. Trust the certificate in your OS first, then reconnect, or use the CodeNomad Electron app."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let parsed = Url::parse(&payload.base_url).map_err(|err| err.to_string())?;
|
||||
let label = format!("remote-{}", payload.id);
|
||||
let title = format!(
|
||||
"{} - {}",
|
||||
payload.name,
|
||||
Url::parse(&payload.base_url)
|
||||
.ok()
|
||||
.and_then(|url| url.host_str().map(str::to_string))
|
||||
.unwrap_or_else(|| payload.base_url.clone())
|
||||
parsed.host_str().unwrap_or(payload.base_url.as_str())
|
||||
);
|
||||
|
||||
let window_url = parsed.clone();
|
||||
|
||||
app.state::<AppState>()
|
||||
.remote_origins
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?
|
||||
.insert(label.clone(), window_url.origin().ascii_serialization());
|
||||
app.state::<AppState>()
|
||||
.remote_skip_tls_verify
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?
|
||||
.insert(label.clone(), parsed.scheme() == "https");
|
||||
|
||||
if let Some(existing) = app.get_webview_window(&label) {
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_tls::ensure_remote_window_tls_handler(&existing, &app, &label)?;
|
||||
|
||||
let _ = existing.navigate(window_url.clone());
|
||||
let _ = existing.navigate(parsed.clone());
|
||||
let _ = existing.set_title(&title);
|
||||
let _ = existing.show();
|
||||
let _ = existing.unminimize();
|
||||
@@ -216,44 +193,25 @@ async fn open_remote_window_impl(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let initial_url = if linux_tls::should_bootstrap_tls_navigation(&window_url, payload.skip_tls_verify)
|
||||
{
|
||||
Url::parse("about:blank").map_err(|err| err.to_string())?
|
||||
} else {
|
||||
window_url.clone()
|
||||
};
|
||||
app.state::<AppState>()
|
||||
.remote_origins
|
||||
.lock()
|
||||
.map_err(|err| err.to_string())?
|
||||
.insert(label.clone(), parsed.origin().ascii_serialization());
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let initial_url = window_url.clone();
|
||||
|
||||
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
|
||||
.title(title)
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
linux_tls::ensure_remote_window_tls_handler(&window, &app, &label)?;
|
||||
if initial_url != window_url {
|
||||
let _ = window.navigate(window_url.clone());
|
||||
}
|
||||
}
|
||||
let window =
|
||||
WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(parsed.clone()))
|
||||
.title(title)
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let app_handle = app.clone();
|
||||
let label_for_cleanup = label.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Destroyed = event {
|
||||
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
||||
origins.remove(&label_for_cleanup);
|
||||
}
|
||||
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
|
||||
values.remove(&label_for_cleanup);
|
||||
}
|
||||
if let Ok(mut handlers) = app_handle.state::<AppState>().remote_tls_handlers.lock() {
|
||||
handlers.remove(&label_for_cleanup);
|
||||
origins.remove(&label);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -261,29 +219,6 @@ async fn open_remote_window_impl(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
|
||||
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
|
||||
if parsed.scheme() == "https" {
|
||||
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
|
||||
format!(
|
||||
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
|
||||
)
|
||||
})?;
|
||||
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
|
||||
return Err(format!(
|
||||
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
open_remote_window_impl(app, payload).await
|
||||
}
|
||||
|
||||
fn collect_directory_paths(paths: &[std::path::PathBuf]) -> Vec<String> {
|
||||
paths
|
||||
.iter()
|
||||
@@ -411,8 +346,6 @@ fn set_windows_app_user_model_id() {
|
||||
fn set_windows_app_user_model_id() {}
|
||||
|
||||
fn main() {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
|
||||
let navigation_guard: TauriPlugin<Wry, ()> = PluginBuilder::new("external-link-guard")
|
||||
.on_navigation(|webview, url| intercept_navigation(webview, url))
|
||||
.build();
|
||||
@@ -440,8 +373,6 @@ fn main() {
|
||||
wake_lock: Mutex::new(None),
|
||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||
remote_origins: Mutex::new(HashMap::new()),
|
||||
remote_skip_tls_verify: Mutex::new(HashMap::new()),
|
||||
remote_tls_handlers: Mutex::new(HashSet::new()),
|
||||
})
|
||||
.setup(|app| {
|
||||
set_windows_app_user_model_id();
|
||||
|
||||
@@ -1,460 +0,0 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri};
|
||||
use axum::response::Response;
|
||||
use axum::routing::any;
|
||||
use axum::Router;
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use futures_util::TryStreamExt;
|
||||
use rand::RngCore;
|
||||
use reqwest::redirect::Policy;
|
||||
use reqwest::Client;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use url::Url;
|
||||
|
||||
const PROXY_TOKEN_QUERY: &str = "proxy_token";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProxyState {
|
||||
client: Client,
|
||||
target_base_url: Url,
|
||||
local_base_url: Url,
|
||||
session_token: String,
|
||||
session_activated: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// TLS configuration for the local HTTPS proxy.
|
||||
pub struct ProxyTlsConfig {
|
||||
pub cert_pem: String,
|
||||
pub key_pem: String,
|
||||
}
|
||||
|
||||
pub struct RemoteProxyHandle {
|
||||
local_base_url: Url,
|
||||
entry_url: Url,
|
||||
target_base_url: Url,
|
||||
skip_tls_verify: bool,
|
||||
server_handle: axum_server::Handle,
|
||||
}
|
||||
|
||||
impl RemoteProxyHandle {
|
||||
pub fn local_base_url(&self) -> &Url {
|
||||
&self.local_base_url
|
||||
}
|
||||
|
||||
pub fn entry_url(&self) -> &Url {
|
||||
&self.entry_url
|
||||
}
|
||||
|
||||
pub fn matches(&self, target_base_url: &Url, skip_tls_verify: bool) -> bool {
|
||||
self.target_base_url == *target_base_url && self.skip_tls_verify == skip_tls_verify
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
self.server_handle.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RemoteProxyHandle {
|
||||
fn drop(&mut self) {
|
||||
self.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_remote_proxy(
|
||||
target_base_url: Url,
|
||||
skip_tls_verify: bool,
|
||||
tls_config: Option<ProxyTlsConfig>,
|
||||
) -> Result<RemoteProxyHandle, String> {
|
||||
let client = Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.danger_accept_invalid_certs(skip_tls_verify)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
// Pre-bind a std TcpListener on port 0 to discover the actual port
|
||||
let std_listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|err| err.to_string())?;
|
||||
let address = std_listener.local_addr().map_err(|err| err.to_string())?;
|
||||
|
||||
let scheme = if tls_config.is_some() { "https" } else { "http" };
|
||||
let local_base_url =
|
||||
Url::parse(&format!("{scheme}://{address}")).map_err(|err| err.to_string())?;
|
||||
let session_token = generate_session_token();
|
||||
let mut entry_url = local_base_url.clone();
|
||||
entry_url.set_path(target_base_url.path());
|
||||
entry_url.set_query(Some(&format!("{PROXY_TOKEN_QUERY}={session_token}")));
|
||||
|
||||
let state = Arc::new(ProxyState {
|
||||
client,
|
||||
target_base_url: target_base_url.clone(),
|
||||
local_base_url: local_base_url.clone(),
|
||||
session_token,
|
||||
session_activated: Arc::new(AtomicBool::new(false)),
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/*path", any(proxy_request))
|
||||
.route("/", any(proxy_request))
|
||||
.with_state(state);
|
||||
|
||||
let server_handle = axum_server::Handle::new();
|
||||
let handle_clone = server_handle.clone();
|
||||
|
||||
if let Some(tls) = tls_config {
|
||||
let rustls_config =
|
||||
RustlsConfig::from_pem(tls.cert_pem.into_bytes(), tls.key_pem.into_bytes())
|
||||
.await
|
||||
.map_err(|err| format!("Failed to build RustlsConfig: {err}"))?;
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let server = axum_server::from_tcp_rustls(std_listener, rustls_config)
|
||||
.handle(handle_clone)
|
||||
.serve(app.into_make_service());
|
||||
|
||||
if let Err(err) = server.await {
|
||||
eprintln!("[tauri] remote proxy (HTTPS) stopped with error: {err}");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let server = axum_server::from_tcp(std_listener)
|
||||
.handle(handle_clone)
|
||||
.serve(app.into_make_service());
|
||||
|
||||
if let Err(err) = server.await {
|
||||
eprintln!("[tauri] remote proxy (HTTP) stopped with error: {err}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RemoteProxyHandle {
|
||||
local_base_url,
|
||||
entry_url,
|
||||
target_base_url,
|
||||
skip_tls_verify,
|
||||
server_handle,
|
||||
})
|
||||
}
|
||||
|
||||
async fn proxy_request(
|
||||
State(state): State<Arc<ProxyState>>,
|
||||
request: Request,
|
||||
) -> Result<Response<Body>, StatusCode> {
|
||||
if !state.session_activated.load(Ordering::SeqCst) {
|
||||
if request_bootstraps_session(&request, &state.session_token) {
|
||||
state.session_activated.store(true, Ordering::SeqCst);
|
||||
return Ok(build_bootstrap_response(request.uri())?);
|
||||
}
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
let upstream_url = build_upstream_url(&state.target_base_url, request.uri())
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
|
||||
let mut builder = state
|
||||
.client
|
||||
.request(request.method().clone(), upstream_url.clone());
|
||||
builder = builder.headers(filter_request_headers(
|
||||
request.headers(),
|
||||
&state.target_base_url,
|
||||
)?);
|
||||
|
||||
let body = axum::body::to_bytes(request.into_body(), usize::MAX)
|
||||
.await
|
||||
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||
if !body.is_empty() {
|
||||
builder = builder.body(body);
|
||||
}
|
||||
|
||||
let upstream = builder.send().await.map_err(map_upstream_error)?;
|
||||
let status = upstream.status();
|
||||
let headers = rewrite_response_headers(
|
||||
upstream.headers(),
|
||||
&state.target_base_url,
|
||||
&state.local_base_url,
|
||||
)?;
|
||||
let stream = upstream
|
||||
.bytes_stream()
|
||||
.map_err(|err| std::io::Error::other(err.to_string()));
|
||||
|
||||
let mut response = Response::new(Body::from_stream(stream));
|
||||
*response.status_mut() = status;
|
||||
*response.headers_mut() = headers;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn build_upstream_url(base_url: &Url, uri: &Uri) -> Result<Url, url::ParseError> {
|
||||
let mut url = base_url.clone();
|
||||
url.set_path(&rewrite_request_path(base_url, uri.path()));
|
||||
url.set_query(strip_proxy_token_query(uri.query()).as_deref());
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn rewrite_request_path(base_url: &Url, request_path: &str) -> String {
|
||||
let base_path = normalized_base_path(base_url);
|
||||
if base_path == "/" {
|
||||
return request_path.to_string();
|
||||
}
|
||||
|
||||
if request_path == "/" {
|
||||
return base_path.to_string();
|
||||
}
|
||||
|
||||
if path_has_base_prefix(base_path, request_path) {
|
||||
return request_path.to_string();
|
||||
}
|
||||
|
||||
format!("{base_path}{request_path}")
|
||||
}
|
||||
|
||||
fn normalized_base_path(base_url: &Url) -> &str {
|
||||
let path = base_url.path();
|
||||
if path.is_empty() { "/" } else { path }
|
||||
}
|
||||
|
||||
fn path_has_base_prefix(base_path: &str, request_path: &str) -> bool {
|
||||
request_path == base_path
|
||||
|| request_path
|
||||
.strip_prefix(base_path)
|
||||
.is_some_and(|suffix| suffix.starts_with('/'))
|
||||
}
|
||||
|
||||
fn generate_session_token() -> String {
|
||||
let mut bytes = [0_u8; 16];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn request_bootstraps_session(request: &Request, session_token: &str) -> bool {
|
||||
request.uri().query().is_some_and(|query| {
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.any(|(name, value)| name == PROXY_TOKEN_QUERY && value == session_token)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_bootstrap_response(uri: &Uri) -> Result<Response<Body>, StatusCode> {
|
||||
let redirect_target = sanitized_request_target(uri);
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::FOUND)
|
||||
.header(axum::http::header::LOCATION, redirect_target)
|
||||
.body(Body::empty())
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
fn sanitized_request_target(uri: &Uri) -> String {
|
||||
let path = if uri.path().is_empty() { "/" } else { uri.path() };
|
||||
match strip_proxy_token_query(uri.query()) {
|
||||
Some(query) if !query.is_empty() => format!("{path}?{query}"),
|
||||
_ => path.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_proxy_token_query(query: Option<&str>) -> Option<String> {
|
||||
let query = query?;
|
||||
let filtered: Vec<(std::borrow::Cow<'_, str>, std::borrow::Cow<'_, str>)> =
|
||||
url::form_urlencoded::parse(query.as_bytes())
|
||||
.filter(|(name, _)| name != PROXY_TOKEN_QUERY)
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
url::form_urlencoded::Serializer::new(String::new())
|
||||
.extend_pairs(filtered)
|
||||
.finish(),
|
||||
)
|
||||
}
|
||||
|
||||
fn filter_request_headers(
|
||||
headers: &HeaderMap,
|
||||
target_base_url: &Url,
|
||||
) -> Result<HeaderMap, StatusCode> {
|
||||
let mut forwarded = HeaderMap::new();
|
||||
for (name, value) in headers {
|
||||
if is_hop_by_hop_header(name) || *name == axum::http::header::HOST {
|
||||
continue;
|
||||
}
|
||||
forwarded.append(name.clone(), value.clone());
|
||||
}
|
||||
|
||||
let host = target_base_url.host_str().ok_or(StatusCode::BAD_REQUEST)?;
|
||||
let host_value = match target_base_url.port() {
|
||||
Some(port) => format!("{host}:{port}"),
|
||||
None => host.to_string(),
|
||||
};
|
||||
forwarded.insert(
|
||||
axum::http::header::HOST,
|
||||
HeaderValue::from_str(&host_value).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
|
||||
let target_origin = target_base_url.origin().ascii_serialization();
|
||||
if let Ok(origin) = HeaderValue::from_str(&target_origin) {
|
||||
forwarded.insert(axum::http::header::ORIGIN, origin);
|
||||
}
|
||||
|
||||
if let Some(referer) = rewrite_referer_header(headers, target_base_url) {
|
||||
forwarded.insert(
|
||||
axum::http::header::REFERER,
|
||||
HeaderValue::from_str(&referer).map_err(|_| StatusCode::BAD_REQUEST)?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(forwarded)
|
||||
}
|
||||
|
||||
fn rewrite_referer_header(headers: &HeaderMap, target_base_url: &Url) -> Option<String> {
|
||||
let referer = headers.get(axum::http::header::REFERER)?.to_str().ok()?;
|
||||
let parsed = Url::parse(referer).ok()?;
|
||||
|
||||
let mut rewritten = target_base_url.clone();
|
||||
rewritten.set_path(&rewrite_request_path(target_base_url, parsed.path()));
|
||||
rewritten.set_query(parsed.query());
|
||||
rewritten.set_fragment(parsed.fragment());
|
||||
Some(rewritten.to_string())
|
||||
}
|
||||
|
||||
fn rewrite_response_headers(
|
||||
headers: &HeaderMap,
|
||||
target_base_url: &Url,
|
||||
local_base_url: &Url,
|
||||
) -> Result<HeaderMap, StatusCode> {
|
||||
let mut rewritten = HeaderMap::new();
|
||||
for (name, value) in headers {
|
||||
if is_hop_by_hop_header(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if *name == axum::http::header::LOCATION {
|
||||
if let Ok(location) = value.to_str() {
|
||||
let next = rewrite_location(location, target_base_url, local_base_url);
|
||||
rewritten.append(
|
||||
name.clone(),
|
||||
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if *name == axum::http::header::SET_COOKIE {
|
||||
if let Ok(cookie) = value.to_str() {
|
||||
let next = rewrite_set_cookie(cookie);
|
||||
rewritten.append(
|
||||
name.clone(),
|
||||
HeaderValue::from_str(&next).map_err(|_| StatusCode::BAD_GATEWAY)?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
rewritten.append(name.clone(), value.clone());
|
||||
}
|
||||
Ok(rewritten)
|
||||
}
|
||||
|
||||
fn rewrite_set_cookie(cookie: &str) -> String {
|
||||
cookie
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|part| !part.get(..7).is_some_and(|prefix| prefix.eq_ignore_ascii_case("Domain=")))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
fn rewrite_location(location: &str, target_base_url: &Url, local_base_url: &Url) -> String {
|
||||
let Ok(parsed) = target_base_url.join(location) else {
|
||||
return location.to_string();
|
||||
};
|
||||
|
||||
if parsed.origin() != target_base_url.origin() {
|
||||
return location.to_string();
|
||||
}
|
||||
|
||||
let mut rewritten = local_base_url.clone();
|
||||
rewritten.set_path(parsed.path());
|
||||
rewritten.set_query(parsed.query());
|
||||
rewritten.set_fragment(parsed.fragment());
|
||||
rewritten.to_string()
|
||||
}
|
||||
|
||||
fn map_upstream_error(error: reqwest::Error) -> StatusCode {
|
||||
if error.is_timeout() {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else if error.is_connect() {
|
||||
StatusCode::BAD_GATEWAY
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hop_by_hop_header(name: &HeaderName) -> bool {
|
||||
static HOP_BY_HOP: std::sync::OnceLock<HashSet<&'static str>> = std::sync::OnceLock::new();
|
||||
HOP_BY_HOP
|
||||
.get_or_init(|| {
|
||||
HashSet::from([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
])
|
||||
})
|
||||
.contains(name.as_str())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn build_upstream_url_prefixes_root_relative_requests_under_base_path() {
|
||||
let base = Url::parse("https://example.com/app").unwrap();
|
||||
let uri = "/api/auth/status?foo=bar".parse::<Uri>().unwrap();
|
||||
|
||||
let upstream = build_upstream_url(&base, &uri).unwrap();
|
||||
|
||||
assert_eq!(upstream.as_str(), "https://example.com/app/api/auth/status?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_upstream_url_keeps_requests_already_under_base_path() {
|
||||
let base = Url::parse("https://example.com/app").unwrap();
|
||||
let uri = "/app/api/auth/status?foo=bar".parse::<Uri>().unwrap();
|
||||
|
||||
let upstream = build_upstream_url(&base, &uri).unwrap();
|
||||
|
||||
assert_eq!(upstream.as_str(), "https://example.com/app/api/auth/status?foo=bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_upstream_url_maps_root_to_base_path() {
|
||||
let base = Url::parse("https://example.com/app").unwrap();
|
||||
let uri = "/".parse::<Uri>().unwrap();
|
||||
|
||||
let upstream = build_upstream_url(&base, &uri).unwrap();
|
||||
|
||||
assert_eq!(upstream.as_str(), "https://example.com/app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_referer_header_prefixes_root_relative_path_under_base_path() {
|
||||
let target = Url::parse("https://example.com/app").unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
axum::http::header::REFERER,
|
||||
HeaderValue::from_static("https://127.0.0.1:3000/api/auth/status?foo=bar"),
|
||||
);
|
||||
|
||||
let referer = rewrite_referer_header(&headers, &target).unwrap();
|
||||
|
||||
assert_eq!(referer, "https://example.com/app/api/auth/status?foo=bar");
|
||||
}
|
||||
}
|
||||
@@ -19,12 +19,60 @@ interface MonacoDiffViewerProps {
|
||||
insertContextLabel?: string
|
||||
}
|
||||
|
||||
function getLineCount(value: string): number {
|
||||
if (!value) return 1
|
||||
return value.split("\n").length
|
||||
}
|
||||
|
||||
function getDigitCount(value: number): number {
|
||||
return String(Math.max(1, value)).length
|
||||
}
|
||||
|
||||
function getUnifiedGutterSizing(options: { before: string; after: string }) {
|
||||
const beforeLineCount = getLineCount(options.before)
|
||||
const afterLineCount = getLineCount(options.after)
|
||||
const beforeDigitCount = getDigitCount(beforeLineCount)
|
||||
const afterDigitCount = getDigitCount(afterLineCount)
|
||||
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
|
||||
const extraDigits = Math.max(0, maxDigitCount - 2)
|
||||
const beforeNumberChars = Math.max(2, beforeDigitCount)
|
||||
const afterNumberChars = Math.max(2, afterDigitCount)
|
||||
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
|
||||
|
||||
return {
|
||||
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
|
||||
originalLineNumbersMinChars: beforeNumberChars,
|
||||
modifiedLineNumbersMinChars: afterNumberChars,
|
||||
lineDecorationsWidth: 6 + extraDigits * 2 + fourDigitPenalty * 2,
|
||||
}
|
||||
}
|
||||
|
||||
function getSplitGutterSizing(options: { before: string; after: string }) {
|
||||
const beforeLineCount = getLineCount(options.before)
|
||||
const afterLineCount = getLineCount(options.after)
|
||||
const beforeDigitCount = getDigitCount(beforeLineCount)
|
||||
const afterDigitCount = getDigitCount(afterLineCount)
|
||||
const maxDigitCount = Math.max(beforeDigitCount, afterDigitCount)
|
||||
const extraDigits = Math.max(0, maxDigitCount - 2)
|
||||
const beforeNumberChars = Math.max(2, beforeDigitCount)
|
||||
const afterNumberChars = Math.max(2, afterDigitCount)
|
||||
const fourDigitPenalty = Math.max(0, maxDigitCount - 3)
|
||||
|
||||
return {
|
||||
diffEditorLineNumbersMinChars: Math.max(beforeNumberChars, afterNumberChars),
|
||||
originalLineNumbersMinChars: beforeNumberChars,
|
||||
modifiedLineNumbersMinChars: afterNumberChars,
|
||||
lineDecorationsWidth: 8 + extraDigits * 2 + fourDigitPenalty,
|
||||
}
|
||||
}
|
||||
|
||||
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
const { isDark } = useTheme()
|
||||
let host: HTMLDivElement | undefined
|
||||
|
||||
let diffEditor: any = null
|
||||
let monaco: any = null
|
||||
let splitLayoutFrame: number | null = null
|
||||
const [ready, setReady] = createSignal(false)
|
||||
const [hoveredLine, setHoveredLine] = createSignal<number | null>(null)
|
||||
const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null)
|
||||
@@ -55,6 +103,44 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
diffEditor = null
|
||||
}
|
||||
|
||||
const clearSplitLayoutVariables = () => {
|
||||
if (!host) return
|
||||
host.style.removeProperty("--split-original-line-number-width")
|
||||
host.style.removeProperty("--split-original-delete-sign-left")
|
||||
host.style.removeProperty("--split-original-gutter-width")
|
||||
}
|
||||
|
||||
const syncSplitLayoutVariables = (options: {
|
||||
viewMode: "split" | "unified"
|
||||
originalLineNumbersMinChars: number
|
||||
lineDecorationsWidth: number
|
||||
}) => {
|
||||
if (!host) return
|
||||
if (splitLayoutFrame !== null && typeof window !== "undefined") {
|
||||
window.cancelAnimationFrame(splitLayoutFrame)
|
||||
splitLayoutFrame = null
|
||||
}
|
||||
if (options.viewMode !== "split" || typeof window === "undefined") {
|
||||
clearSplitLayoutVariables()
|
||||
return
|
||||
}
|
||||
|
||||
splitLayoutFrame = window.requestAnimationFrame(() => {
|
||||
splitLayoutFrame = null
|
||||
if (!host) return
|
||||
const originalLineNumbers = host.querySelector<HTMLElement>(".editor.original .line-numbers")
|
||||
const measuredWidth = originalLineNumbers?.getBoundingClientRect().width ?? 0
|
||||
const lineNumberWidth =
|
||||
measuredWidth > 0 ? measuredWidth : Math.max(12, options.originalLineNumbersMinChars * 6)
|
||||
host.style.setProperty("--split-original-line-number-width", `${lineNumberWidth}px`)
|
||||
host.style.setProperty("--split-original-delete-sign-left", `${lineNumberWidth}px`)
|
||||
host.style.setProperty(
|
||||
"--split-original-gutter-width",
|
||||
`${lineNumberWidth + options.lineDecorationsWidth}px`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const getModifiedEditor = () => diffEditor?.getModifiedEditor?.() ?? null
|
||||
|
||||
const getActiveInsertRange = () => {
|
||||
@@ -120,7 +206,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
renderWhitespace: "selection",
|
||||
fontSize: 13,
|
||||
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
||||
glyphMargin: true,
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||
lineNumbersMinChars: 4,
|
||||
@@ -139,6 +225,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
|
||||
onCleanup(() => {
|
||||
cancelled = true
|
||||
if (splitLayoutFrame !== null && typeof window !== "undefined") {
|
||||
window.cancelAnimationFrame(splitLayoutFrame)
|
||||
splitLayoutFrame = null
|
||||
}
|
||||
clearSplitLayoutVariables()
|
||||
setReady(false)
|
||||
disposeEditor()
|
||||
})
|
||||
@@ -149,6 +240,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!host) return
|
||||
host.dataset.viewMode = props.viewMode === "split" ? "split" : "unified"
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!ready() || !monaco || !diffEditor) return
|
||||
const modifiedEditor = diffEditor.getModifiedEditor?.()
|
||||
@@ -222,10 +318,23 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
||||
|
||||
const { before, after } = resolvedContent()
|
||||
const sizing =
|
||||
viewMode === "unified"
|
||||
? getUnifiedGutterSizing({ before, after })
|
||||
: getSplitGutterSizing({ before, after })
|
||||
const {
|
||||
diffEditorLineNumbersMinChars,
|
||||
originalLineNumbersMinChars,
|
||||
modifiedLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
} = sizing
|
||||
diffEditor.updateOptions({
|
||||
renderSideBySide: viewMode === "split",
|
||||
renderSideBySideInlineBreakpoint: 0,
|
||||
renderIndicators: true,
|
||||
lineNumbersMinChars: diffEditorLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
hideUnchangedRegions:
|
||||
contextMode === "collapsed"
|
||||
? { enabled: true }
|
||||
@@ -234,16 +343,30 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||
})
|
||||
|
||||
try {
|
||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({
|
||||
wordWrap,
|
||||
lineNumbersMinChars: originalLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({
|
||||
wordWrap,
|
||||
lineNumbersMinChars: modifiedLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
syncSplitLayoutVariables({
|
||||
viewMode,
|
||||
originalLineNumbersMinChars,
|
||||
lineDecorationsWidth,
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||
import { openExternalUrl } from "../lib/external-url"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { runtimeEnv } from "../lib/runtime-env"
|
||||
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
@@ -333,15 +332,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
})
|
||||
|
||||
if (openWindow) {
|
||||
const windowUrl =
|
||||
runtimeEnv.host === "tauri"
|
||||
? (await serverApi.createRemoteProxySession({
|
||||
baseUrl: profile.baseUrl,
|
||||
skipTlsVerify: profile.skipTlsVerify,
|
||||
})).windowUrl
|
||||
: undefined
|
||||
|
||||
await openRemoteServerWindow(profile, windowUrl)
|
||||
await openRemoteServerWindow(profile)
|
||||
await markRemoteServerConnected(profile.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -384,6 +384,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
||||
onContextModeChange={props.onContextModeChange}
|
||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||
/>
|
||||
|
||||
</>
|
||||
}
|
||||
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
|
||||
|
||||
@@ -581,113 +581,6 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
autoCapitalize="off"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="prompt-nav-buttons">
|
||||
<div class="prompt-nav-column prompt-nav-column-left">
|
||||
<Show when={showVoiceInput()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
beginVoicePress(event)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.preventDefault()
|
||||
endVoicePress()
|
||||
}}
|
||||
onPointerCancel={() => endVoicePress()}
|
||||
onLostPointerCapture={() => endVoicePress()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.repeat) return
|
||||
if (event.key !== " " && event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
beginVoicePress(event)
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key !== " " && event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
endVoicePress()
|
||||
}}
|
||||
onBlur={() => endVoicePress()}
|
||||
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
|
||||
aria-label={voiceInput.buttonTitle()}
|
||||
title={voiceInput.buttonTitle()}
|
||||
>
|
||||
<Show
|
||||
when={voiceInput.isRecording()}
|
||||
fallback={
|
||||
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
|
||||
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Mic class="h-4 w-4" aria-hidden="true" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showConversationToggle()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
||||
onClick={() => toggleConversationMode(props.instanceId)}
|
||||
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
||||
aria-pressed={conversationModeEnabled()}
|
||||
aria-label={conversationModeButtonTitle()}
|
||||
title={conversationModeButtonTitle()}
|
||||
>
|
||||
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-clear-button"
|
||||
onClick={handleClearPrompt}
|
||||
disabled={!canClearPrompt()}
|
||||
aria-label={t("promptInput.clear.ariaLabel")}
|
||||
title={t("promptInput.clear.title")}
|
||||
>
|
||||
<X class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-nav-column prompt-nav-column-right">
|
||||
<ExpandButton
|
||||
expandState={expandState}
|
||||
onToggleExpand={handleExpandToggle}
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectPreviousHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectNextHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={shouldShowOverlay()}>
|
||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||
<Show
|
||||
@@ -742,6 +635,116 @@ export default function PromptInput(props: PromptInputProps) {
|
||||
</div>
|
||||
|
||||
<div class="prompt-input-actions">
|
||||
<div class="prompt-nav-buttons">
|
||||
<div class="prompt-nav-column prompt-nav-column-left">
|
||||
<Show when={showVoiceInput()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button ${voiceInput.isRecording() ? "is-recording" : ""}`}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
beginVoicePress(event)
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
event.preventDefault()
|
||||
endVoicePress()
|
||||
}}
|
||||
onPointerCancel={() => endVoicePress()}
|
||||
onLostPointerCapture={() => endVoicePress()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.repeat) return
|
||||
if (event.key !== " " && event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
beginVoicePress(event)
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key !== " " && event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
endVoicePress()
|
||||
}}
|
||||
onBlur={() => endVoicePress()}
|
||||
disabled={!voiceInput.isRecording() && (props.disabled || voiceInput.isTranscribing() || !voiceInput.canUseVoiceInput())}
|
||||
aria-label={voiceInput.buttonTitle()}
|
||||
title={voiceInput.buttonTitle()}
|
||||
>
|
||||
<Show
|
||||
when={voiceInput.isRecording()}
|
||||
fallback={
|
||||
<Show when={voiceInput.isTranscribing()} fallback={<Mic class="h-4 w-4" aria-hidden="true" />}>
|
||||
<Loader2 class="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Mic class="h-4 w-4" aria-hidden="true" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={showConversationToggle()}>
|
||||
<button
|
||||
type="button"
|
||||
class={`prompt-voice-button prompt-nav-voice-button prompt-conversation-button ${conversationModeEnabled() ? "is-active" : ""}`}
|
||||
onClick={() => toggleConversationMode(props.instanceId)}
|
||||
disabled={!conversationModeEnabled() && !canToggleConversationMode()}
|
||||
aria-pressed={conversationModeEnabled()}
|
||||
aria-label={conversationModeButtonTitle()}
|
||||
title={conversationModeButtonTitle()}
|
||||
>
|
||||
<Volume2 class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-clear-button"
|
||||
onClick={handleClearPrompt}
|
||||
disabled={!canClearPrompt()}
|
||||
aria-label={t("promptInput.clear.ariaLabel")}
|
||||
title={t("promptInput.clear.title")}
|
||||
>
|
||||
<X class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="prompt-nav-column prompt-nav-column-right">
|
||||
<ExpandButton
|
||||
expandState={expandState}
|
||||
onToggleExpand={handleExpandToggle}
|
||||
/>
|
||||
<Show when={hasHistory()}>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectPreviousHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoPrevious()}
|
||||
aria-label={t("promptInput.history.previousAriaLabel")}
|
||||
>
|
||||
<ArrowBigUp class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="prompt-history-button"
|
||||
onClick={() =>
|
||||
selectNextHistory({
|
||||
force: true,
|
||||
isPickerOpen: showPicker(),
|
||||
getTextarea: () => textareaRef,
|
||||
})
|
||||
}
|
||||
disabled={!canHistoryGoNext()}
|
||||
aria-label={t("promptInput.history.nextAriaLabel")}
|
||||
>
|
||||
<ArrowBigDown class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prompt-input-primary-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="stop-button"
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
||||
|
||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
|
||||
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
|
||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||
|
||||
@@ -375,11 +374,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
||||
const relativeTop = targetRect.top - containerRect.top
|
||||
const exceedsViewport = targetRect.height > element.clientHeight
|
||||
|
||||
if (
|
||||
exceedsViewport &&
|
||||
relativeTop <= holdTargetTopThresholdPx() &&
|
||||
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
|
||||
) {
|
||||
if (exceedsViewport && relativeTop < 0) {
|
||||
const alignDelta = relativeTop - holdTargetTopThresholdPx()
|
||||
if (Math.abs(alignDelta) > 1) {
|
||||
element.scrollTop = Math.max(0, element.scrollTop + alignDelta)
|
||||
}
|
||||
setHeldItemCount(itemCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,14 @@ type WorktreeOption =
|
||||
| { kind: "action"; key: "__create__"; label: string }
|
||||
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
||||
|
||||
type DeleteErrorKind = "localChanges" | "inUse" | "notFound" | "permissionDenied" | "unknown"
|
||||
|
||||
type DeleteErrorDetails = {
|
||||
summary: string
|
||||
causeLabel: string
|
||||
nextStep: string
|
||||
}
|
||||
|
||||
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
||||
// Prevent Select.Item from treating this as a selection.
|
||||
// We intentionally prevent default to stop Kobalte's internal press handling.
|
||||
@@ -64,6 +72,57 @@ function relativePath(fromDir: string, toDir: string): string {
|
||||
return relParts.join("/") || "."
|
||||
}
|
||||
|
||||
function extractDeleteErrorMessage(input: string): string {
|
||||
const trimmed = (input ?? "").trim()
|
||||
if (!trimmed) return ""
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as { error?: unknown }
|
||||
if (typeof parsed?.error === "string" && parsed.error.trim()) {
|
||||
return parsed.error.trim()
|
||||
}
|
||||
} catch {
|
||||
// Fall back to the raw string when the backend returned plain text.
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function classifyDeleteError(message: string): DeleteErrorKind {
|
||||
const normalized = message.toLowerCase()
|
||||
|
||||
if (
|
||||
normalized.includes("modified or untracked files") ||
|
||||
normalized.includes("contains modified") ||
|
||||
normalized.includes("contains untracked") ||
|
||||
normalized.includes("use --force to delete it")
|
||||
) {
|
||||
return "localChanges"
|
||||
}
|
||||
|
||||
if (
|
||||
normalized.includes("in use") ||
|
||||
normalized.includes("resource busy") ||
|
||||
normalized.includes("device or resource busy") ||
|
||||
normalized.includes("ebusy") ||
|
||||
normalized.includes("file is being used") ||
|
||||
normalized.includes("process cannot access the file") ||
|
||||
normalized.includes("directory not empty")
|
||||
) {
|
||||
return "inUse"
|
||||
}
|
||||
|
||||
if (normalized.includes("not found") || normalized.includes("no such file") || normalized.includes("cannot find")) {
|
||||
return "notFound"
|
||||
}
|
||||
|
||||
if (normalized.includes("permission denied") || normalized.includes("access is denied") || normalized.includes("eperm")) {
|
||||
return "permissionDenied"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
interface WorktreeSelectorProps {
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
@@ -80,6 +139,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
|
||||
const [forceDelete, setForceDelete] = createSignal(false)
|
||||
const [isDeleting, setIsDeleting] = createSignal(false)
|
||||
const [deleteError, setDeleteError] = createSignal<string | null>(null)
|
||||
|
||||
const session = createMemo(() => sessions().get(props.instanceId)?.get(props.sessionId))
|
||||
const isChildSession = createMemo(() => Boolean(session()?.parentId))
|
||||
@@ -114,10 +174,16 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
|
||||
if (opt.slug === "root") return
|
||||
setForceDelete(false)
|
||||
setDeleteError(null)
|
||||
setDeleteTarget(opt)
|
||||
setDeleteOpen(true)
|
||||
}
|
||||
|
||||
const closeDeleteDialog = () => {
|
||||
setDeleteOpen(false)
|
||||
setDeleteError(null)
|
||||
}
|
||||
|
||||
const repoRoot = createMemo(() => {
|
||||
const list = getWorktrees(props.instanceId)
|
||||
return list.find((wt) => wt.slug === "root")?.directory ?? ""
|
||||
@@ -139,6 +205,89 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeDeleteError = (input: string) => {
|
||||
let sanitized = (input ?? "").trim()
|
||||
if (!sanitized) {
|
||||
return t("instanceShell.worktree.delete.error.fallback")
|
||||
}
|
||||
|
||||
sanitized = sanitized.replace(/[A-Za-z]:[\\/][^\r\n"']+/g, "[path]")
|
||||
sanitized = sanitized.replace(/\\Users\\[^\\/\r\n]+/gi, "\\Users\\[user]")
|
||||
sanitized = sanitized.replace(/\/Users\/[^/\r\n]+/g, "/Users/[user]")
|
||||
sanitized = sanitized.replace(/\/home\/[^/\r\n]+/g, "/home/[user]")
|
||||
sanitized = sanitized.replace(/([A-Za-z]:[\\/])?Users[\\/][^\\/\r\n]+/gi, "$1Users/[user]")
|
||||
return sanitized
|
||||
}
|
||||
|
||||
const handleCopyDeleteError = async (mode: "raw" | "sanitized") => {
|
||||
const raw = deleteError()
|
||||
if (!raw) return
|
||||
const text = mode === "sanitized" ? sanitizeDeleteError(raw) : raw
|
||||
|
||||
try {
|
||||
const ok = await copyToClipboard(text)
|
||||
showToastNotification({
|
||||
message: ok
|
||||
? t(mode === "sanitized" ? "instanceShell.worktree.delete.error.copySanitizedSuccess" : "instanceShell.worktree.delete.error.copySuccess")
|
||||
: t("instanceShell.worktree.delete.error.copyFailure"),
|
||||
variant: ok ? "success" : "error",
|
||||
})
|
||||
} catch (error) {
|
||||
log.error("Failed to copy delete worktree error", error)
|
||||
showToastNotification({
|
||||
message: t("instanceShell.worktree.delete.error.copyFailure"),
|
||||
variant: "error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const deleteErrorDetails = createMemo<DeleteErrorDetails | null>(() => {
|
||||
const raw = deleteError()
|
||||
if (!raw) return null
|
||||
|
||||
const parsed = extractDeleteErrorMessage(raw)
|
||||
const kind = classifyDeleteError(parsed)
|
||||
|
||||
switch (kind) {
|
||||
case "localChanges":
|
||||
return {
|
||||
summary: t("instanceShell.worktree.delete.error.summary.localChanges"),
|
||||
causeLabel: t("instanceShell.worktree.delete.error.cause.localChanges"),
|
||||
nextStep: t("instanceShell.worktree.delete.error.nextStep.localChanges"),
|
||||
}
|
||||
case "inUse":
|
||||
return {
|
||||
summary: t("instanceShell.worktree.delete.error.summary.inUse"),
|
||||
causeLabel: t("instanceShell.worktree.delete.error.cause.inUse"),
|
||||
nextStep: t("instanceShell.worktree.delete.error.nextStep.inUse"),
|
||||
}
|
||||
case "notFound":
|
||||
return {
|
||||
summary: t("instanceShell.worktree.delete.error.summary.notFound"),
|
||||
causeLabel: t("instanceShell.worktree.delete.error.cause.notFound"),
|
||||
nextStep: t("instanceShell.worktree.delete.error.nextStep.notFound"),
|
||||
}
|
||||
case "permissionDenied":
|
||||
return {
|
||||
summary: t("instanceShell.worktree.delete.error.summary.permissionDenied"),
|
||||
causeLabel: t("instanceShell.worktree.delete.error.cause.permissionDenied"),
|
||||
nextStep: t("instanceShell.worktree.delete.error.nextStep.permissionDenied"),
|
||||
}
|
||||
default:
|
||||
return {
|
||||
summary: t("instanceShell.worktree.delete.error.summary.unknown"),
|
||||
causeLabel: t("instanceShell.worktree.delete.error.cause.unknown"),
|
||||
nextStep: t("instanceShell.worktree.delete.error.nextStep.unknown"),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const displayDeleteError = createMemo(() => {
|
||||
const raw = deleteError()
|
||||
if (!raw) return null
|
||||
return extractDeleteErrorMessage(raw)
|
||||
})
|
||||
|
||||
const handleChange = async (value: WorktreeOption | null) => {
|
||||
if (worktreesUnavailable()) return
|
||||
if (!value) return
|
||||
@@ -343,22 +492,23 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
</Dialog.Portal>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && setDeleteOpen(false)}>
|
||||
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && closeDeleteDialog()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="modal-overlay" />
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-3 md:p-4">
|
||||
<Dialog.Content class="modal-surface w-[clamp(640px,45vw,960px)] max-w-[calc(100vw-2rem)] max-h-[calc(100vh-2rem)] overflow-y-auto p-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
|
||||
<Dialog.Description class="text-sm text-secondary mt-2">Removes the git worktree checkout directory for this branch.</Dialog.Description>
|
||||
<Dialog.Description class="text-sm text-secondary mt-1">Deletes this branch worktree and its local folder.</Dialog.Description>
|
||||
</div>
|
||||
|
||||
<Show when={deleteTarget()}>
|
||||
{(target) => (
|
||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
|
||||
<p class="text-sm font-mono text-primary break-all">{target().slug}</p>
|
||||
<p class="text-[11px] text-secondary mt-2 break-all font-mono">{target().directory}</p>
|
||||
<div class="rounded-lg border border-base bg-surface-secondary px-3 py-2">
|
||||
<p class="text-sm text-primary">
|
||||
Worktree <span class="font-semibold font-mono">"{target().slug}"</span>
|
||||
</p>
|
||||
<p class="text-[11px] text-secondary break-all font-mono leading-5">{target().directory}</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
@@ -377,7 +527,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => setDeleteOpen(false)}
|
||||
onClick={closeDeleteDialog}
|
||||
disabled={isDeleting()}
|
||||
>
|
||||
Cancel
|
||||
@@ -389,12 +539,13 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
onClick={() => {
|
||||
const target = deleteTarget()
|
||||
if (!target) {
|
||||
setDeleteOpen(false)
|
||||
closeDeleteDialog()
|
||||
return
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
setIsDeleting(true)
|
||||
setDeleteError(null)
|
||||
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
|
||||
await reloadWorktrees(props.instanceId)
|
||||
await reloadWorktreeMap(props.instanceId)
|
||||
@@ -403,15 +554,12 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
|
||||
}
|
||||
|
||||
setDeleteOpen(false)
|
||||
closeDeleteDialog()
|
||||
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
|
||||
})()
|
||||
.catch((error) => {
|
||||
log.warn("Failed to delete worktree", error)
|
||||
showToastNotification({
|
||||
message: error instanceof Error ? error.message : "Failed to delete worktree",
|
||||
variant: "error",
|
||||
})
|
||||
setDeleteError(error instanceof Error ? error.message : t("instanceShell.worktree.delete.error.fallback"))
|
||||
})
|
||||
.finally(() => {
|
||||
setIsDeleting(false)
|
||||
@@ -421,6 +569,56 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
||||
{isDeleting() ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Show when={displayDeleteError()}>
|
||||
{(message) => (
|
||||
<div class="rounded-lg border border-danger bg-danger/10 p-3 flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-xs font-medium text-danger uppercase tracking-wide">
|
||||
{t("instanceShell.worktree.delete.error.title")}
|
||||
</p>
|
||||
<Show when={deleteErrorDetails()}>
|
||||
{(details) => (
|
||||
<>
|
||||
<p class="text-sm text-primary font-medium">{details().summary}</p>
|
||||
<p class="text-sm text-secondary">
|
||||
<span class="font-medium text-primary">{t("instanceShell.worktree.delete.error.causeLabel")}</span>{" "}
|
||||
{details().causeLabel}
|
||||
</p>
|
||||
<p class="text-sm text-secondary">
|
||||
<span class="font-medium text-primary">{t("instanceShell.worktree.delete.error.nextStepLabel")}</span>{" "}
|
||||
{details().nextStep}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<pre class="max-h-[40vh] overflow-auto whitespace-pre-wrap break-all rounded border border-danger/30 bg-surface-primary px-3 py-2 text-xs text-primary select-text leading-5">{message()}</pre>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => {
|
||||
void handleCopyDeleteError("raw")
|
||||
}}
|
||||
>
|
||||
{t("instanceShell.worktree.delete.error.copyRaw")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary"
|
||||
onClick={() => {
|
||||
void handleCopyDeleteError("sanitized")
|
||||
}}
|
||||
>
|
||||
{t("instanceShell.worktree.delete.error.copySanitized")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
|
||||
@@ -12,8 +12,6 @@ import type {
|
||||
SpeechTranscriptionResponse,
|
||||
SideCar,
|
||||
ServerMeta,
|
||||
RemoteProxySessionCreateRequest,
|
||||
RemoteProxySessionCreateResponse,
|
||||
RemoteServerProbeRequest,
|
||||
RemoteServerProbeResponse,
|
||||
VoiceModeStateResponse,
|
||||
@@ -258,12 +256,6 @@ export const serverApi = {
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
createRemoteProxySession(payload: RemoteProxySessionCreateRequest): Promise<RemoteProxySessionCreateResponse> {
|
||||
return request<RemoteProxySessionCreateResponse>("/api/remote-proxy/sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
||||
},
|
||||
|
||||
@@ -158,6 +158,30 @@ export const instanceMessages = {
|
||||
"instanceShell.diff.enableWordWrap": "Enable word wrap",
|
||||
"instanceShell.diff.disableWordWrap": "Disable word wrap",
|
||||
"instanceShell.worktree.create": "+ Create worktree",
|
||||
"instanceShell.worktree.delete.error.title": "Delete failed",
|
||||
"instanceShell.worktree.delete.error.fallback": "Failed to delete worktree",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "Likely cause:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "Suggested next step:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git refused to delete this worktree because it has modified or untracked files.",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad could not delete this worktree because something is still using files in the directory.",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad could not delete this worktree because the directory or worktree record was not found.",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad could not delete this worktree because access to the directory was denied.",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad could not delete this worktree.",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "Local changes",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "Another process is using this worktree",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "The worktree directory or record is missing",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "Insufficient filesystem permissions",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "The backend returned an unclassified delete error",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "Enable Force delete if you want to discard local changes, or clean the worktree and try again.",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "Close terminals, editors, watchers, or background processes using this worktree and try again.",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "Refresh worktrees and try again. If it still fails, inspect the worktree path on disk.",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Check filesystem permissions and close applications that may be locking this directory, then try again.",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "Review the raw error below for details, then retry after addressing the reported problem.",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "Copy error",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "Copy sanitized",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "Copied delete error",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Copied sanitized delete error",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "Failed to copy delete error",
|
||||
|
||||
"instanceShell.plan.noSessionSelected": "Select a session to view plan.",
|
||||
"instanceShell.plan.empty": "Nothing planned yet.",
|
||||
|
||||
@@ -166,6 +166,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "Salida",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "Detener",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminar",
|
||||
"instanceShell.worktree.delete.error.title": "Error al eliminar",
|
||||
"instanceShell.worktree.delete.error.fallback": "Error al eliminar el worktree",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "Causa probable:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "Siguiente paso sugerido:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git rechazo la eliminacion de este worktree porque contiene archivos modificados o sin seguimiento.",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad no pudo eliminar este worktree porque algo sigue usando archivos dentro del directorio.",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad no pudo eliminar este worktree porque no se encontro el directorio o el registro del worktree.",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad no pudo eliminar este worktree porque se denego el acceso al directorio.",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad no pudo eliminar este worktree.",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "Cambios locales",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "Otro proceso esta usando este worktree",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "Falta el directorio o el registro del worktree",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "Permisos insuficientes del sistema de archivos",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "El backend devolvio un error de eliminacion sin clasificar",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "Activa Forzar eliminacion si quieres descartar los cambios locales, o limpia el worktree e intentalo de nuevo.",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "Cierra terminales, editores, observadores o procesos en segundo plano que usen este worktree y vuelve a intentarlo.",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "Recarga los worktrees y vuelve a intentarlo. Si sigue fallando, inspecciona la ruta del worktree en disco.",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Revisa los permisos del sistema de archivos y cierra aplicaciones que puedan estar bloqueando este directorio, luego vuelve a intentarlo.",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "Revisa el error sin procesar de abajo para ver los detalles y vuelve a intentarlo despues de corregir el problema indicado.",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "Copiar error",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "Copiar saneado",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "Error de eliminacion copiado",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Error de eliminacion saneado copiado",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "No se pudo copiar el error de eliminacion",
|
||||
|
||||
"versionPill.appWithVersion": "App {version}",
|
||||
"versionPill.ui": "UI",
|
||||
|
||||
@@ -166,6 +166,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "Sortie",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "Arrêter",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminer",
|
||||
"instanceShell.worktree.delete.error.title": "Echec de suppression",
|
||||
"instanceShell.worktree.delete.error.fallback": "Impossible de supprimer le worktree",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "Cause probable :",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "Etape suivante suggeree :",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git a refuse de supprimer ce worktree car il contient des fichiers modifies ou non suivis.",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad n'a pas pu supprimer ce worktree car quelque chose utilise encore des fichiers dans ce dossier.",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad n'a pas pu supprimer ce worktree car le dossier ou l'enregistrement du worktree est introuvable.",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad n'a pas pu supprimer ce worktree car l'acces au dossier a ete refuse.",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad n'a pas pu supprimer ce worktree.",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "Modifications locales",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "Un autre processus utilise ce worktree",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "Le dossier ou l'enregistrement du worktree est manquant",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "Permissions du systeme de fichiers insuffisantes",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "Le backend a renvoye une erreur de suppression non classee",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "Activez la suppression forcee si vous voulez jeter les modifications locales, ou nettoyez le worktree puis reessayez.",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "Fermez les terminaux, editeurs, observateurs ou processus en arrière-plan qui utilisent ce worktree puis reessayez.",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "Rechargez les worktrees puis reessayez. Si cela echoue encore, inspectez le chemin du worktree sur le disque.",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Verifiez les permissions du systeme de fichiers et fermez les applications qui peuvent verrouiller ce dossier, puis reessayez.",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "Consultez l'erreur brute ci-dessous pour les details, puis reessayez apres avoir corrige le probleme signale.",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "Copier l'erreur",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "Copier la version nettoyee",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "Erreur de suppression copiee",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Erreur de suppression nettoyee copiee",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "Impossible de copier l'erreur de suppression",
|
||||
|
||||
"versionPill.appWithVersion": "Appli {version}",
|
||||
"versionPill.ui": "UI",
|
||||
|
||||
@@ -174,6 +174,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "פלט",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "עצור",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
|
||||
"instanceShell.worktree.delete.error.title": "המחיקה נכשלה",
|
||||
"instanceShell.worktree.delete.error.fallback": "מחיקת ה-worktree נכשלה",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "סיבה סבירה:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "השלב הבא המומלץ:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git סירב למחוק את ה-worktree הזה כי יש בו קבצים ששונו או קבצים לא במעקב.",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי משהו עדיין משתמש בקבצים שבתיקייה.",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי התיקייה או רשומת ה-worktree לא נמצאו.",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad לא הצליח למחוק את ה-worktree הזה כי הגישה לתיקייה נדחתה.",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad לא הצליח למחוק את ה-worktree הזה.",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "שינויים מקומיים",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "תהליך אחר משתמש ב-worktree הזה",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "תיקיית ה-worktree או הרשומה שלו חסרות",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "אין הרשאות מתאימות במערכת הקבצים",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "ה-backend החזיר שגיאת מחיקה שלא סווגה",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "הפעילו מחיקה בכפייה אם אתם רוצים לזרוק את השינויים המקומיים, או נקו את ה-worktree ונסו שוב.",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "סגרו טרמינלים, עורכים, watchers או תהליכי רקע שמשתמשים ב-worktree הזה ונסו שוב.",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "רעננו את רשימת ה-worktrees ונסו שוב. אם זה עדיין נכשל, בדקו את נתיב ה-worktree על הדיסק.",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "בדקו את הרשאות מערכת הקבצים וסגרו אפליקציות שעשויות לנעול את התיקייה הזאת, ואז נסו שוב.",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "עיינו בשגיאה הגולמית למטה לפרטים, ואז נסו שוב אחרי טיפול בבעיה שדווחה.",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "העתק שגיאה",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "העתק גרסה מסוננת",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "שגיאת המחיקה הועתקה",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "שגיאת המחיקה המסוננת הועתקה",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "העתקת שגיאת המחיקה נכשלה",
|
||||
|
||||
"versionPill.appWithVersion": "אפליקציה {version}",
|
||||
"versionPill.ui": "ממשק",
|
||||
|
||||
@@ -166,6 +166,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "出力",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "停止",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "終了",
|
||||
"instanceShell.worktree.delete.error.title": "削除に失敗しました",
|
||||
"instanceShell.worktree.delete.error.fallback": "worktree の削除に失敗しました",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "考えられる原因:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "推奨される次の手順:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "この worktree に変更済みまたは未追跡のファイルがあるため、Git が削除を拒否しました。",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "このディレクトリ内のファイルがまだ使用中のため、CodeNomad はこの worktree を削除できませんでした。",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "ディレクトリまたは worktree レコードが見つからなかったため、CodeNomad はこの worktree を削除できませんでした。",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "ディレクトリへのアクセスが拒否されたため、CodeNomad はこの worktree を削除できませんでした。",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad はこの worktree を削除できませんでした。",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "ローカル変更",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "別のプロセスがこの worktree を使用中です",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "worktree のディレクトリまたは記録が見つかりません",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "ファイルシステム権限が不足しています",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "バックエンドが分類できない削除エラーを返しました",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "ローカル変更を破棄したい場合は Force delete を有効にするか、worktree を整理してから再試行してください。",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "この worktree を使用している端末、エディタ、watcher、バックグラウンドプロセスを閉じてから再試行してください。",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "worktree 一覧を更新して再試行してください。まだ失敗する場合は、ディスク上の worktree パスを確認してください。",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "ファイルシステム権限を確認し、このディレクトリをロックしている可能性のあるアプリを閉じてから再試行してください。",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "下の生エラーで詳細を確認し、報告された問題に対処してから再試行してください。",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "エラーをコピー",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "サニタイズ済みをコピー",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "削除エラーをコピーしました",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "サニタイズ済みの削除エラーをコピーしました",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "削除エラーをコピーできませんでした",
|
||||
|
||||
"versionPill.appWithVersion": "アプリ {version}",
|
||||
"versionPill.ui": "UI",
|
||||
|
||||
@@ -166,6 +166,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "Вывод",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "Остановить",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "Завершить",
|
||||
"instanceShell.worktree.delete.error.title": "Удаление не удалось",
|
||||
"instanceShell.worktree.delete.error.fallback": "Не удалось удалить worktree",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "Вероятная причина:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "Рекомендуемый следующий шаг:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git отказался удалять этот worktree, потому что в нем есть измененные или неотслеживаемые файлы.",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad не смог удалить этот worktree, потому что что-то все еще использует файлы в каталоге.",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad не смог удалить этот worktree, потому что каталог или запись worktree не найдены.",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad не смог удалить этот worktree, потому что доступ к каталогу был запрещен.",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad не смог удалить этот worktree.",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "Локальные изменения",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "Другой процесс использует этот worktree",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "Каталог или запись worktree отсутствуют",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "Недостаточно прав файловой системы",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "Бэкенд вернул неклассифицированную ошибку удаления",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "Включите принудительное удаление, если хотите отбросить локальные изменения, либо очистите worktree и попробуйте снова.",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "Закройте терминалы, редакторы, watcher-процессы или фоновые процессы, использующие этот worktree, и попробуйте снова.",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "Обновите список worktree и попробуйте снова. Если ошибка сохранится, проверьте путь worktree на диске.",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "Проверьте права файловой системы и закройте приложения, которые могут удерживать этот каталог, затем попробуйте снова.",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "Посмотрите необработанную ошибку ниже, затем попробуйте снова после устранения указанной проблемы.",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "Копировать ошибку",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "Копировать обезличенную",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "Ошибка удаления скопирована",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "Обезличенная ошибка удаления скопирована",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "Не удалось скопировать ошибку удаления",
|
||||
|
||||
"versionPill.appWithVersion": "Приложение {version}",
|
||||
"versionPill.ui": "UI",
|
||||
|
||||
@@ -166,6 +166,30 @@ export const instanceMessages = {
|
||||
"instanceShell.backgroundProcesses.actions.output": "输出",
|
||||
"instanceShell.backgroundProcesses.actions.stop": "停止",
|
||||
"instanceShell.backgroundProcesses.actions.terminate": "终止",
|
||||
"instanceShell.worktree.delete.error.title": "删除失败",
|
||||
"instanceShell.worktree.delete.error.fallback": "删除 worktree 失败",
|
||||
"instanceShell.worktree.delete.error.causeLabel": "可能原因:",
|
||||
"instanceShell.worktree.delete.error.nextStepLabel": "建议的下一步:",
|
||||
"instanceShell.worktree.delete.error.summary.localChanges": "Git 拒绝删除这个 worktree,因为其中包含已修改或未跟踪的文件。",
|
||||
"instanceShell.worktree.delete.error.summary.inUse": "CodeNomad 无法删除这个 worktree,因为目录中的文件仍在被某些进程使用。",
|
||||
"instanceShell.worktree.delete.error.summary.notFound": "CodeNomad 无法删除这个 worktree,因为目录或 worktree 记录未找到。",
|
||||
"instanceShell.worktree.delete.error.summary.permissionDenied": "CodeNomad 无法删除这个 worktree,因为目录访问被拒绝。",
|
||||
"instanceShell.worktree.delete.error.summary.unknown": "CodeNomad 无法删除这个 worktree。",
|
||||
"instanceShell.worktree.delete.error.cause.localChanges": "本地更改",
|
||||
"instanceShell.worktree.delete.error.cause.inUse": "另一个进程正在使用这个 worktree",
|
||||
"instanceShell.worktree.delete.error.cause.notFound": "worktree 目录或记录缺失",
|
||||
"instanceShell.worktree.delete.error.cause.permissionDenied": "文件系统权限不足",
|
||||
"instanceShell.worktree.delete.error.cause.unknown": "后端返回了未分类的删除错误",
|
||||
"instanceShell.worktree.delete.error.nextStep.localChanges": "如果你想丢弃本地更改,请启用强制删除,或者先清理 worktree 后再重试。",
|
||||
"instanceShell.worktree.delete.error.nextStep.inUse": "关闭正在使用这个 worktree 的终端、编辑器、watcher 或后台进程,然后再试一次。",
|
||||
"instanceShell.worktree.delete.error.nextStep.notFound": "刷新 worktree 列表后再试一次。如果仍然失败,请检查磁盘上的 worktree 路径。",
|
||||
"instanceShell.worktree.delete.error.nextStep.permissionDenied": "检查文件系统权限,并关闭可能锁定此目录的应用程序,然后再试一次。",
|
||||
"instanceShell.worktree.delete.error.nextStep.unknown": "查看下方原始错误详情,并在处理提示的问题后再次重试。",
|
||||
"instanceShell.worktree.delete.error.copyRaw": "复制错误",
|
||||
"instanceShell.worktree.delete.error.copySanitized": "复制脱敏内容",
|
||||
"instanceShell.worktree.delete.error.copySuccess": "已复制删除错误",
|
||||
"instanceShell.worktree.delete.error.copySanitizedSuccess": "已复制脱敏后的删除错误",
|
||||
"instanceShell.worktree.delete.error.copyFailure": "复制删除错误失败",
|
||||
|
||||
"versionPill.appWithVersion": "应用 {version}",
|
||||
"versionPill.ui": "UI",
|
||||
|
||||
@@ -6,19 +6,14 @@ export interface RemoteWindowOpenPayload {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
entryUrl?: string
|
||||
skipTlsVerify: boolean
|
||||
}
|
||||
|
||||
export async function openRemoteServerWindow(
|
||||
profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">,
|
||||
entryUrl?: string,
|
||||
): Promise<void> {
|
||||
export async function openRemoteServerWindow(profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">): Promise<void> {
|
||||
const payload: RemoteWindowOpenPayload = {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
baseUrl: profile.baseUrl,
|
||||
entryUrl,
|
||||
skipTlsVerify: profile.skipTlsVerify,
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
.prompt-input-wrapper {
|
||||
@apply grid items-stretch;
|
||||
grid-template-columns: minmax(0, 1fr) 64px;
|
||||
grid-template-columns: minmax(0, 1fr) 72px 64px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -19,6 +19,16 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.prompt-input-primary-actions {
|
||||
@apply flex flex-col items-center;
|
||||
align-self: stretch;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-inline-start: 1px solid var(--border-base);
|
||||
}
|
||||
|
||||
.prompt-input-field-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -37,7 +47,7 @@
|
||||
.prompt-input {
|
||||
@apply w-full pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||
padding-inline-start: 0.75rem;
|
||||
padding-inline-end: 7.5rem;
|
||||
padding-inline-end: 0.75rem;
|
||||
font-family: inherit;
|
||||
background-color: var(--surface-base);
|
||||
color: var(--text-primary);
|
||||
@@ -85,16 +95,12 @@
|
||||
|
||||
/* Navigation buttons container (expand, prev, next). */
|
||||
.prompt-nav-buttons {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
inset-inline-end: 0.25rem;
|
||||
bottom: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
justify-content: center;
|
||||
gap: 0.125rem;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.prompt-nav-column {
|
||||
@@ -287,7 +293,6 @@
|
||||
@apply w-10 h-10 rounded-md border-none cursor-pointer flex items-center justify-center transition-all flex-shrink-0;
|
||||
background-color: var(--accent-primary);
|
||||
color: var(--text-inverted);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.send-button.shell-mode {
|
||||
@@ -421,7 +426,7 @@
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.prompt-input-wrapper {
|
||||
grid-template-columns: minmax(0, 1fr) 40px;
|
||||
grid-template-columns: minmax(0, 1fr) 64px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,7 +434,6 @@
|
||||
.prompt-input {
|
||||
min-height: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-inline-end: 7.5rem;
|
||||
padding-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -611,6 +611,40 @@
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="unified"] .line-numbers {
|
||||
text-align: left !important;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .line-numbers,
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.modified .line-numbers {
|
||||
text-align: left !important;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .glyph-margin {
|
||||
width: 0 !important;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .line-numbers {
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .cldr.delete-sign {
|
||||
left: var(--split-original-delete-sign-left, 14px) !important;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .margin,
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .margin-view-zones,
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .margin-view-overlays {
|
||||
width: var(--split-original-gutter-width, 24px) !important;
|
||||
}
|
||||
|
||||
.file-viewer-content--monaco .monaco-viewer[data-view-mode="split"] .editor.original .editor-scrollable {
|
||||
left: var(--split-original-gutter-width, 24px) !important;
|
||||
width: calc(100% - var(--split-original-gutter-width, 24px)) !important;
|
||||
}
|
||||
|
||||
.file-viewer-empty {
|
||||
@apply flex flex-col items-center justify-center h-full gap-3 text-center;
|
||||
color: var(--text-muted);
|
||||
|
||||
1
packages/ui/src/types/global.d.ts
vendored
1
packages/ui/src/types/global.d.ts
vendored
@@ -37,7 +37,6 @@ declare global {
|
||||
id: string
|
||||
name: string
|
||||
baseUrl: string
|
||||
entryUrl?: string
|
||||
skipTlsVerify: boolean
|
||||
}) => Promise<{ ok: boolean }>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user