Compare commits
13 Commits
feat/linux
...
v0.14.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04fc28c492 | ||
|
|
623a09fd7e | ||
|
|
b00aa7ef84 | ||
|
|
acfa265595 | ||
|
|
35b171764e | ||
|
|
6b53ab2d73 | ||
|
|
1b829094ef | ||
|
|
e28e9f5879 | ||
|
|
cb84547c88 | ||
|
|
e022a158eb | ||
|
|
9d9a6a79ec | ||
|
|
82a7c95dba | ||
|
|
313a0e579e |
973
package-lock.json
generated
973
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -29,8 +29,14 @@
|
|||||||
"google-auth-library": "^10.5.0"
|
"google-auth-library": "^10.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@esbuild/darwin-arm64": "^0.28.0",
|
|
||||||
"@rollup/rollup-darwin-arm64": "^4.60.2",
|
|
||||||
"baseline-browser-mapping": "^2.9.11"
|
"baseline-browser-mapping": "^2.9.11"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rollup/rollup-darwin-arm64": "4.52.5",
|
||||||
|
"@rollup/rollup-darwin-x64": "4.52.5",
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": "4.52.5",
|
||||||
|
"@rollup/rollup-linux-x64-gnu": "4.52.5",
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": "4.52.5",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.52.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
"vite-plugin-solid": "^2.10.0"
|
"vite-plugin-solid": "^2.10.0"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "ai.opencode.client",
|
"appId": "ai.neuralnomads.codenomad.client",
|
||||||
"productName": "CodeNomad",
|
"productName": "CodeNomad",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release",
|
"output": "release",
|
||||||
|
|||||||
@@ -343,6 +343,7 @@ export interface RemoteProxySessionCreateRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteProxySessionCreateResponse {
|
export interface RemoteProxySessionCreateResponse {
|
||||||
|
sessionId: string
|
||||||
windowUrl: string
|
windowUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -376,10 +376,6 @@ async function main() {
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
if (uiResolution.uiDevServerUrl && options.https) {
|
|
||||||
throw new InvalidArgumentError("UI dev proxy is only supported with --https=false --http=true")
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
const remoteAccessEnabled = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
|
|
||||||
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
const clientConnectionManager = new ClientConnectionManager(logger.child({ component: "client-connections" }))
|
||||||
|
|||||||
248
packages/server/src/server/__tests__/remote-proxy.test.ts
Normal file
248
packages/server/src/server/__tests__/remote-proxy.test.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import { after, afterEach, describe, it } from "node:test"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import http, { type IncomingMessage, type ServerResponse } from "node:http"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
import { Agent, fetch } from "undici"
|
||||||
|
|
||||||
|
import type { AuthManager } from "../../auth/manager"
|
||||||
|
import type { Logger } from "../../logger"
|
||||||
|
import { RemoteProxySessionManager } from "../remote-proxy"
|
||||||
|
import { resolveHttpsOptions } from "../tls"
|
||||||
|
|
||||||
|
const sharedTempDir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-remote-proxy-test-"))
|
||||||
|
const sharedTls = resolveHttpsOptions({
|
||||||
|
enabled: true,
|
||||||
|
configDir: sharedTempDir,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
logger: createStubLogger(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!sharedTls) {
|
||||||
|
throw new Error("Failed to generate HTTPS options for remote proxy tests")
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharedHttpsOptions = sharedTls.httpsOptions
|
||||||
|
|
||||||
|
const httpsDispatcher = new Agent({ connect: { rejectUnauthorized: false } })
|
||||||
|
const managers = new Set<RemoteProxySessionManager>()
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const manager of managers) {
|
||||||
|
await disposeManager(manager)
|
||||||
|
}
|
||||||
|
managers.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
after(() => {
|
||||||
|
fs.rmSync(sharedTempDir, { recursive: true, force: true })
|
||||||
|
httpsDispatcher.close().catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("RemoteProxySessionManager", () => {
|
||||||
|
it("blocks proxying before activation and keeps bootstrap tokens scoped per session", async () => {
|
||||||
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
||||||
|
const manager = createSessionManager()
|
||||||
|
const session1 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
const session2 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
|
const blocked = await proxyFetch(`${session1.proxyOrigin}/status`)
|
||||||
|
assert.equal(blocked.status, 403)
|
||||||
|
|
||||||
|
const wrongTokenResponse = await proxyFetch(`${session1.proxyOrigin}/__codenomad/api/auth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: session2.token }),
|
||||||
|
})
|
||||||
|
assert.equal(wrongTokenResponse.status, 401)
|
||||||
|
|
||||||
|
assert.equal(await activateSession(session1), true)
|
||||||
|
assert.equal(await activateSession(session2), true)
|
||||||
|
}, (req, res) => {
|
||||||
|
res.writeHead(200, { "content-type": "text/plain" })
|
||||||
|
res.end(req.url ?? "")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves remote base paths and rewrites same-origin redirects to the local proxy origin", async () => {
|
||||||
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
||||||
|
const manager = createSessionManager()
|
||||||
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
|
await activateSession(session)
|
||||||
|
|
||||||
|
const apiResponse = await proxyFetch(`${session.proxyOrigin}/api/auth/status?foo=bar`)
|
||||||
|
assert.equal(apiResponse.status, 200)
|
||||||
|
assert.equal(await apiResponse.text(), "/base/api/auth/status?foo=bar")
|
||||||
|
|
||||||
|
const redirectResponse = await proxyFetch(`${session.proxyOrigin}/redirect`, { redirect: "manual" })
|
||||||
|
assert.equal(redirectResponse.status, 302)
|
||||||
|
assert.equal(redirectResponse.headers.get("location"), `${session.proxyOrigin}/base/after?ok=1`)
|
||||||
|
}, (req, res) => {
|
||||||
|
const requestUrl = req.url ?? ""
|
||||||
|
if (requestUrl === "/base/redirect") {
|
||||||
|
res.writeHead(302, { location: "/base/after?ok=1" })
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, { "content-type": "text/plain" })
|
||||||
|
res.end(requestUrl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rewrites set-cookie names for the proxy and restores cookie names on proxied requests", async () => {
|
||||||
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
||||||
|
const manager = createSessionManager()
|
||||||
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
|
await activateSession(session)
|
||||||
|
|
||||||
|
const loginResponse = await proxyFetch(`${session.proxyOrigin}/login`)
|
||||||
|
assert.equal(loginResponse.status, 200)
|
||||||
|
const setCookie = getSetCookie(loginResponse)[0]
|
||||||
|
|
||||||
|
assert.match(setCookie, /^cnrp_[0-9a-f]+_session=abc123/i)
|
||||||
|
assert.doesNotMatch(setCookie, /domain=/i)
|
||||||
|
|
||||||
|
const cookieHeader = setCookie.split(";", 1)[0]
|
||||||
|
const whoamiResponse = await proxyFetch(`${session.proxyOrigin}/whoami`, {
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(await whoamiResponse.text(), "session=abc123")
|
||||||
|
}, (req, res) => {
|
||||||
|
const requestUrl = req.url ?? ""
|
||||||
|
if (requestUrl === "/base/login") {
|
||||||
|
res.writeHead(200, {
|
||||||
|
"content-type": "text/plain",
|
||||||
|
"set-cookie": "session=abc123; Path=/; Secure; HttpOnly; Domain=127.0.0.1",
|
||||||
|
})
|
||||||
|
res.end("ok")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestUrl === "/base/whoami") {
|
||||||
|
res.writeHead(200, { "content-type": "text/plain" })
|
||||||
|
res.end(req.headers.cookie ?? "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404, { "content-type": "text/plain" })
|
||||||
|
res.end(requestUrl)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("supports explicit deletion and idle cleanup of sessions", async () => {
|
||||||
|
await withUpstreamServer(async (upstreamBaseUrl) => {
|
||||||
|
const manager = createSessionManager()
|
||||||
|
const session = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
|
||||||
|
assert.equal(await manager.deleteSession(session.sessionId), true)
|
||||||
|
assert.equal(await manager.deleteSession(session.sessionId), false)
|
||||||
|
|
||||||
|
const session3 = await createSession(manager, `${upstreamBaseUrl}/base`)
|
||||||
|
const internalSessions = (manager as any).sessions as Map<string, { lastAccessAt: number }>
|
||||||
|
const internalCleanup = (manager as any).cleanupExpiredSessions as () => Promise<void>
|
||||||
|
|
||||||
|
internalSessions.get(session3.sessionId)!.lastAccessAt = Date.now() - 31 * 60_000
|
||||||
|
await internalCleanup.call(manager)
|
||||||
|
|
||||||
|
assert.equal(internalSessions.has(session3.sessionId), false)
|
||||||
|
assert.equal(await manager.deleteSession(session3.sessionId), false)
|
||||||
|
}, (_req, res) => {
|
||||||
|
res.writeHead(200, { "content-type": "text/plain" })
|
||||||
|
res.end("ok")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function createSessionManager() {
|
||||||
|
const manager = new RemoteProxySessionManager({
|
||||||
|
authManager: {
|
||||||
|
isLoopbackRequest: () => true,
|
||||||
|
} as unknown as AuthManager,
|
||||||
|
logger: createStubLogger(),
|
||||||
|
httpsOptions: sharedHttpsOptions,
|
||||||
|
})
|
||||||
|
managers.add(manager)
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession(manager: RemoteProxySessionManager, baseUrl: string) {
|
||||||
|
const created = await manager.createSession(baseUrl, false)
|
||||||
|
const windowUrl = new URL(created.windowUrl)
|
||||||
|
return {
|
||||||
|
sessionId: created.sessionId,
|
||||||
|
windowUrl,
|
||||||
|
proxyOrigin: windowUrl.origin,
|
||||||
|
token: decodeURIComponent(windowUrl.hash.replace(/^#/, "")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateSession(session: { proxyOrigin: string; token: string }) {
|
||||||
|
const response = await proxyFetch(`${session.proxyOrigin}/__codenomad/api/auth/token`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: session.token }),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as { ok?: boolean }
|
||||||
|
return body.ok === true
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSetCookie(response: Awaited<ReturnType<typeof fetch>>): string[] {
|
||||||
|
const values = (response.headers as any).getSetCookie?.() as string[] | undefined
|
||||||
|
if (Array.isArray(values) && values.length > 0) {
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
const fallback = response.headers.get("set-cookie")
|
||||||
|
return fallback ? [fallback] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function proxyFetch(url: string, init?: Parameters<typeof fetch>[1]) {
|
||||||
|
return fetch(url, { dispatcher: httpsDispatcher, ...init })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disposeManager(manager: RemoteProxySessionManager) {
|
||||||
|
const sessions = Array.from(((manager as any).sessions as Map<string, unknown>).keys())
|
||||||
|
for (const sessionId of sessions) {
|
||||||
|
await manager.deleteSession(sessionId)
|
||||||
|
}
|
||||||
|
clearInterval((manager as any).cleanupTimer as NodeJS.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withUpstreamServer(
|
||||||
|
callback: (baseUrl: string) => Promise<void>,
|
||||||
|
handler: (req: IncomingMessage, res: ServerResponse<IncomingMessage>) => void,
|
||||||
|
) {
|
||||||
|
const server = http.createServer(handler)
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const address = server.address()
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("Failed to resolve upstream server address")
|
||||||
|
}
|
||||||
|
await callback(`http://127.0.0.1:${address.port}`)
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStubLogger(): Logger {
|
||||||
|
const logger = {
|
||||||
|
info() {},
|
||||||
|
warn() {},
|
||||||
|
error() {},
|
||||||
|
child() {
|
||||||
|
return logger
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return logger as unknown as Logger
|
||||||
|
}
|
||||||
@@ -202,7 +202,12 @@ export function createHttpServer(deps: HttpServerDeps) {
|
|||||||
publicPagePaths.add("/auth/token")
|
publicPagePaths.add("/auth/token")
|
||||||
}
|
}
|
||||||
|
|
||||||
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname)) {
|
const isLoopbackRemoteProxyDelete =
|
||||||
|
request.method === "DELETE" &&
|
||||||
|
pathname.startsWith("/api/remote-proxy/sessions/") &&
|
||||||
|
deps.authManager.isLoopbackRequest(request)
|
||||||
|
|
||||||
|
if (publicApiPaths.has(pathname) || publicPagePaths.has(pathname) || isLoopbackRemoteProxyDelete) {
|
||||||
done()
|
done()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify"
|
||||||
import { randomBytes, randomUUID } from "crypto"
|
import { randomBytes, randomUUID } from "crypto"
|
||||||
import { Readable } from "stream"
|
import { Readable } from "stream"
|
||||||
|
import { pipeline } from "stream/promises"
|
||||||
import { Agent, fetch } from "undici"
|
import { Agent, fetch } from "undici"
|
||||||
import type { AuthManager } from "../auth/manager"
|
import type { AuthManager } from "../auth/manager"
|
||||||
import type { Logger } from "../logger"
|
import type { Logger } from "../logger"
|
||||||
@@ -12,6 +13,7 @@ const SESSION_IDLE_TTL_MS = 30 * 60_000
|
|||||||
|
|
||||||
interface RemoteProxySession {
|
interface RemoteProxySession {
|
||||||
id: string
|
id: string
|
||||||
|
bootstrapToken: string
|
||||||
targetBaseUrl: URL
|
targetBaseUrl: URL
|
||||||
skipTlsVerify: boolean
|
skipTlsVerify: boolean
|
||||||
localBaseUrl: URL
|
localBaseUrl: URL
|
||||||
@@ -31,6 +33,11 @@ export interface RemoteProxySessionManagerOptions {
|
|||||||
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
httpsOptions?: { key: string | Buffer; cert: string | Buffer; ca?: string | Buffer }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteProxySessionCreateResult {
|
||||||
|
sessionId: string
|
||||||
|
windowUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export class RemoteProxySessionManager {
|
export class RemoteProxySessionManager {
|
||||||
private readonly sessions = new Map<string, RemoteProxySession>()
|
private readonly sessions = new Map<string, RemoteProxySession>()
|
||||||
private readonly cleanupTimer: NodeJS.Timeout
|
private readonly cleanupTimer: NodeJS.Timeout
|
||||||
@@ -42,24 +49,21 @@ export class RemoteProxySessionManager {
|
|||||||
this.cleanupTimer.unref()
|
this.cleanupTimer.unref()
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<string> {
|
async createSession(baseUrl: string, skipTlsVerify: boolean): Promise<RemoteProxySessionCreateResult> {
|
||||||
if (!this.options.httpsOptions) {
|
if (!this.options.httpsOptions) {
|
||||||
throw new Error("Local HTTPS is required for remote proxy sessions")
|
throw new Error("Local HTTPS is required for remote proxy sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetBaseUrl = normalizeBaseUrl(baseUrl)
|
const targetBaseUrl = normalizeBaseUrl(baseUrl)
|
||||||
const token = this.options.authManager.issueBootstrapToken()
|
|
||||||
if (!token) {
|
|
||||||
throw new Error("Bootstrap token generation is unavailable")
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = randomUUID()
|
const sessionId = randomUUID()
|
||||||
|
const bootstrapToken = randomBytes(32).toString("base64url")
|
||||||
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
const dispatcher = skipTlsVerify ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined
|
||||||
const app = Fastify({ logger: false, https: this.options.httpsOptions })
|
const app = Fastify({ logger: false, https: this.options.httpsOptions })
|
||||||
let session: RemoteProxySession | null = null
|
let session: RemoteProxySession | null = null
|
||||||
|
|
||||||
app.removeAllContentTypeParsers()
|
app.removeAllContentTypeParsers()
|
||||||
app.addContentTypeParser("*", (req, body, done) => done(null, body))
|
// Preserve raw request bodies for proxying while still letting token JSON parse from Buffer.
|
||||||
|
app.addContentTypeParser("*", { parseAs: "buffer" }, (_req, body, done) => done(null, body))
|
||||||
|
|
||||||
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
|
app.get(BOOTSTRAP_PAGE_PATH, async (request, reply) => {
|
||||||
if (!this.options.authManager.isLoopbackRequest(request)) {
|
if (!this.options.authManager.isLoopbackRequest(request)) {
|
||||||
@@ -79,14 +83,14 @@ export class RemoteProxySessionManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = parseTokenBody(request.body)
|
if (!session) {
|
||||||
if (!this.options.authManager.consumeBootstrapToken(body.token)) {
|
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
||||||
reply.code(401).send({ error: "Invalid token" })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
const body = parseTokenBody(request.body)
|
||||||
reply.code(503).send({ error: "Remote proxy session is unavailable" })
|
if (body.token !== session.bootstrapToken) {
|
||||||
|
reply.code(401).send({ error: "Invalid token" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,11 +137,12 @@ export class RemoteProxySessionManager {
|
|||||||
|
|
||||||
session = {
|
session = {
|
||||||
id: sessionId,
|
id: sessionId,
|
||||||
|
bootstrapToken,
|
||||||
targetBaseUrl,
|
targetBaseUrl,
|
||||||
skipTlsVerify,
|
skipTlsVerify,
|
||||||
localBaseUrl,
|
localBaseUrl,
|
||||||
entryUrl,
|
entryUrl,
|
||||||
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(token)}`,
|
bootstrapUrl: `${localBaseUrl.origin}${BOOTSTRAP_PAGE_PATH}?returnTo=${encodeURIComponent(returnTo)}#${encodeURIComponent(bootstrapToken)}`,
|
||||||
activated: false,
|
activated: false,
|
||||||
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
|
cookiePrefix: `cnrp_${randomBytes(6).toString("hex")}_`,
|
||||||
app,
|
app,
|
||||||
@@ -152,7 +157,11 @@ export class RemoteProxySessionManager {
|
|||||||
"Created remote proxy session",
|
"Created remote proxy session",
|
||||||
)
|
)
|
||||||
|
|
||||||
return session.bootstrapUrl
|
return { sessionId, windowUrl: session.bootstrapUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSession(sessionId: string): Promise<boolean> {
|
||||||
|
return this.disposeSession(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cleanupExpiredSessions() {
|
private async cleanupExpiredSessions() {
|
||||||
@@ -165,16 +174,17 @@ export class RemoteProxySessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async disposeSession(sessionId: string) {
|
private async disposeSession(sessionId: string): Promise<boolean> {
|
||||||
const session = this.sessions.get(sessionId)
|
const session = this.sessions.get(sessionId)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sessions.delete(sessionId)
|
this.sessions.delete(sessionId)
|
||||||
session.dispatcher?.close().catch(() => {})
|
session.dispatcher?.close().catch(() => {})
|
||||||
await session.app.close().catch(() => {})
|
await session.app.close().catch(() => {})
|
||||||
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
|
this.options.logger.info({ sessionId }, "Disposed remote proxy session")
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,7 +346,9 @@ async function proxyRequest(args: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.send(Readable.fromWeb(response.body as any))
|
reply.hijack()
|
||||||
|
reply.raw.writeHead(reply.statusCode, toOutgoingHeaders(reply.getHeaders()))
|
||||||
|
await pipeline(Readable.fromWeb(response.body as any), reply.raw)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
|
logger.error({ err: error, upstreamUrl }, "Failed to proxy remote session request")
|
||||||
if (!reply.sent) {
|
if (!reply.sent) {
|
||||||
@@ -394,7 +406,12 @@ function filterRequestHeaders(
|
|||||||
for (const [key, value] of Object.entries(headers ?? {})) {
|
for (const [key, value] of Object.entries(headers ?? {})) {
|
||||||
if (!value) continue
|
if (!value) continue
|
||||||
const lower = key.toLowerCase()
|
const lower = key.toLowerCase()
|
||||||
if (isHopByHopHeader(lower) || lower === "host" || lower === "content-length") {
|
if (
|
||||||
|
isHopByHopHeader(lower) ||
|
||||||
|
lower === "host" ||
|
||||||
|
lower === "content-length" ||
|
||||||
|
lower === "accept-encoding"
|
||||||
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (lower === "origin") {
|
if (lower === "origin") {
|
||||||
@@ -452,7 +469,12 @@ function applyResponseHeaders(reply: FastifyReply, response: any, session: Remot
|
|||||||
|
|
||||||
response.headers.forEach((value: string, key: string) => {
|
response.headers.forEach((value: string, key: string) => {
|
||||||
const lower = key.toLowerCase()
|
const lower = key.toLowerCase()
|
||||||
if (isHopByHopHeader(lower) || lower === "set-cookie") {
|
if (
|
||||||
|
isHopByHopHeader(lower) ||
|
||||||
|
lower === "set-cookie" ||
|
||||||
|
lower === "content-length" ||
|
||||||
|
lower === "content-encoding"
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,6 +487,17 @@ function applyResponseHeaders(reply: FastifyReply, response: any, session: Remot
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toOutgoingHeaders(headers: ReturnType<FastifyReply["getHeaders"]>): Record<string, string | string[]> {
|
||||||
|
const next: Record<string, string | string[]> = {}
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
if (value === undefined) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next[key] = Array.isArray(value) ? value.map(String) : String(value)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
|
function rewriteSetCookie(cookie: string, cookiePrefix: string): string {
|
||||||
const parts = cookie.split(";").map((part) => part.trim())
|
const parts = cookie.split(";").map((part) => part.trim())
|
||||||
const first = parts.shift() ?? ""
|
const first = parts.shift() ?? ""
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyInstance } from "fastify"
|
import type { FastifyInstance } from "fastify"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { RemoteProxySessionCreateResponse } from "../../api-types"
|
import type { RemoteProxySessionCreateResponse } from "../../api-types"
|
||||||
|
import { isLoopbackAddress } from "../../auth/http-auth"
|
||||||
import type { Logger } from "../../logger"
|
import type { Logger } from "../../logger"
|
||||||
import type { RemoteProxySessionManager } from "../remote-proxy"
|
import type { RemoteProxySessionManager } from "../remote-proxy"
|
||||||
|
|
||||||
@@ -14,16 +15,40 @@ const CreateSessionSchema = z.object({
|
|||||||
skipTlsVerify: z.boolean().optional(),
|
skipTlsVerify: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const SessionParamsSchema = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
})
|
||||||
|
|
||||||
export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerRemoteProxyRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
app.post("/api/remote-proxy/sessions", async (request, reply): Promise<RemoteProxySessionCreateResponse | { error: string }> => {
|
app.post("/api/remote-proxy/sessions", async (request, reply): Promise<RemoteProxySessionCreateResponse | { error: string }> => {
|
||||||
try {
|
try {
|
||||||
const body = CreateSessionSchema.parse(request.body ?? {})
|
const body = CreateSessionSchema.parse(request.body ?? {})
|
||||||
const windowUrl = await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
|
return await deps.sessionManager.createSession(body.baseUrl, Boolean(body.skipTlsVerify))
|
||||||
return { windowUrl }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
deps.logger.warn({ err: error }, "Failed to create remote proxy session")
|
deps.logger.warn({ err: error }, "Failed to create remote proxy session")
|
||||||
reply.code(400)
|
reply.code(400)
|
||||||
return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" }
|
return { error: error instanceof Error ? error.message : "Failed to create remote proxy session" }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.delete("/api/remote-proxy/sessions/:id", async (request, reply): Promise<{ ok: boolean } | { error: string }> => {
|
||||||
|
if (!isLoopbackAddress(request.socket.remoteAddress)) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Not found" }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = SessionParamsSchema.parse(request.params ?? {})
|
||||||
|
const deleted = await deps.sessionManager.deleteSession(params.id)
|
||||||
|
if (!deleted) {
|
||||||
|
reply.code(404)
|
||||||
|
return { error: "Remote proxy session not found" }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
} catch (error) {
|
||||||
|
deps.logger.warn({ err: error }, "Failed to delete remote proxy session")
|
||||||
|
reply.code(400)
|
||||||
|
return { error: error instanceof Error ? error.message : "Failed to delete remote proxy session" }
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
151
packages/tauri-app/Cargo.lock
generated
151
packages/tauri-app/Cargo.lock
generated
@@ -47,15 +47,6 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -244,83 +235,6 @@ dependencies = [
|
|||||||
"fs_extra",
|
"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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.7"
|
version = "0.21.7"
|
||||||
@@ -586,17 +500,11 @@ name = "codenomad-tauri"
|
|||||||
version = "0.14.0"
|
version = "0.14.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
|
||||||
"axum-server",
|
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"futures-util",
|
|
||||||
"keepawake",
|
"keepawake",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"rand 0.8.5",
|
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -609,8 +517,6 @@ dependencies = [
|
|||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tokio",
|
|
||||||
"url",
|
"url",
|
||||||
"webkit2gtk",
|
"webkit2gtk",
|
||||||
"which",
|
"which",
|
||||||
@@ -1283,16 +1189,6 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "fs_extra"
|
name = "fs_extra"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -1872,12 +1768,6 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpdate"
|
|
||||||
version = "1.0.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@@ -1892,7 +1782,6 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
"httpdate",
|
|
||||||
"itoa",
|
"itoa",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"pin-utils",
|
"pin-utils",
|
||||||
@@ -2441,12 +2330,6 @@ version = "0.1.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "matchit"
|
|
||||||
version = "0.7.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.0"
|
version = "2.8.0"
|
||||||
@@ -3670,15 +3553,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
@@ -3892,17 +3766,6 @@ dependencies = [
|
|||||||
"zmij",
|
"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]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -4812,21 +4675,9 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-rustls"
|
name = "tokio-rustls"
|
||||||
version = "0.26.4"
|
version = "0.26.4"
|
||||||
@@ -4968,7 +4819,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5007,7 +4857,6 @@ version = "0.1.44"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tracing-attributes",
|
"tracing-attributes",
|
||||||
"tracing-core",
|
"tracing-core",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"build": "tauri build"
|
"build": "tauri build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2.9.4",
|
"@tauri-apps/cli": "^2.9.4"
|
||||||
"@tauri-apps/cli-darwin-arm64": "^2.9.4"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ const braceExpansionPath = path.join(
|
|||||||
"package.json",
|
"package.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const serverBuildDependencyPaths = [
|
||||||
|
path.join(serverRoot, "node_modules", "typescript", "package.json"),
|
||||||
|
path.join(serverRoot, "node_modules", "@types", "node-forge", "package.json"),
|
||||||
|
path.join(serverRoot, "node_modules", "@types", "yauzl", "package.json"),
|
||||||
|
]
|
||||||
|
|
||||||
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
const viteBinPath = path.join(uiRoot, "node_modules", ".bin", "vite")
|
||||||
|
|
||||||
async function ensureMonacoAssets() {
|
async function ensureMonacoAssets() {
|
||||||
@@ -98,7 +104,7 @@ function syncServerUiBundle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureServerDevDependencies() {
|
function ensureServerDevDependencies() {
|
||||||
if (fs.existsSync(braceExpansionPath)) {
|
if (serverBuildDependencyPaths.every((filePath) => fs.existsSync(filePath))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +148,7 @@ function ensureRollupPlatformBinary() {
|
|||||||
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
|
"linux-arm64": "@rollup/rollup-linux-arm64-gnu",
|
||||||
"darwin-arm64": "@rollup/rollup-darwin-arm64",
|
"darwin-arm64": "@rollup/rollup-darwin-arm64",
|
||||||
"darwin-x64": "@rollup/rollup-darwin-x64",
|
"darwin-x64": "@rollup/rollup-darwin-x64",
|
||||||
|
"win32-arm64": "@rollup/rollup-win32-arm64-msvc",
|
||||||
"win32-x64": "@rollup/rollup-win32-x64-msvc",
|
"win32-x64": "@rollup/rollup-win32-x64-msvc",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,20 +12,12 @@ tauri = { version = "2.5.2", features = [ "devtools"] }
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
axum = "0.7"
|
|
||||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
bytes = "1"
|
|
||||||
futures-util = "0.3"
|
|
||||||
rustls = { version = "0.23", features = ["ring"] }
|
rustls = { version = "0.23", features = ["ring"] }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["http2", "charset", "json", "stream", "rustls-tls"] }
|
||||||
rand = "0.8"
|
|
||||||
regex = "1"
|
regex = "1"
|
||||||
once_cell = "1"
|
|
||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
thiserror = "1"
|
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "sync"] }
|
|
||||||
which = "4"
|
which = "4"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
keepawake = "0.6"
|
keepawake = "0.6"
|
||||||
|
|||||||
BIN
packages/tauri-app/src-tauri/icons/linux/128x128.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/256x256.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/256x256.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/32x32.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/48x48.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/48x48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/512x512.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
BIN
packages/tauri-app/src-tauri/icons/linux/64x64.png
Normal file
BIN
packages/tauri-app/src-tauri/icons/linux/64x64.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Categories=
|
||||||
|
Exec=codenomad-tauri
|
||||||
|
StartupWMClass=codenomad-tauri
|
||||||
|
Icon=codenomad-tauri
|
||||||
|
Name=CodeNomad
|
||||||
|
NoDisplay=true
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
@@ -152,6 +152,10 @@ fn trusted_marker_value(cert_der: &[u8]) -> String {
|
|||||||
cert_der.iter().map(|byte| format!("{byte:02x}")).collect()
|
cert_der.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn trusted_marker_file_suffix(cert_der: &[u8]) -> String {
|
||||||
|
trusted_marker_value(cert_der).chars().take(16).collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn has_matching_trusted_marker(cert_der: &[u8]) -> bool {
|
fn has_matching_trusted_marker(cert_der: &[u8]) -> bool {
|
||||||
trusted_marker_path()
|
trusted_marker_path()
|
||||||
.ok()
|
.ok()
|
||||||
@@ -170,8 +174,11 @@ fn write_trusted_marker(cert_der: &[u8]) -> Result<(), String> {
|
|||||||
.map_err(|e| format!("Failed to write trust marker: {e}"))
|
.map_err(|e| format!("Failed to write trust marker: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Adds the DER-encoded CA certificate to the Windows `CurrentUser\Root` store.
|
#[cfg(windows)]
|
||||||
/// This will show a one-time Windows security confirmation dialog when needed.
|
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
|
||||||
|
Ok(!windows_cert_is_trusted(cert_der)?)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
|
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
|
||||||
use windows_sys::Win32::Security::Cryptography::{
|
use windows_sys::Win32::Security::Cryptography::{
|
||||||
@@ -179,7 +186,7 @@ pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
|
|||||||
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
|
CERT_STORE_ADD_REPLACE_EXISTING, PKCS_7_ASN_ENCODING, X509_ASN_ENCODING,
|
||||||
};
|
};
|
||||||
|
|
||||||
if has_matching_trusted_marker(cert_der) {
|
if !needs_trust_in_store(cert_der)? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +222,201 @@ pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(windows))]
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn needs_trust_in_store(cert_der: &[u8]) -> Result<bool, String> {
|
||||||
|
Ok(!(has_matching_trusted_marker(cert_der) && macos_cert_is_trusted(cert_der)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn trust_cert_in_store(cert_der: &[u8]) -> Result<(), String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
if !needs_trust_in_store(cert_der)? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_path = env::temp_dir().join(format!(
|
||||||
|
"codenomad-server-ca-{}.cer",
|
||||||
|
trusted_marker_file_suffix(cert_der)
|
||||||
|
));
|
||||||
|
fs::write(&temp_path, cert_der)
|
||||||
|
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
|
||||||
|
|
||||||
|
let keychain_path = resolve_macos_user_keychain()?;
|
||||||
|
|
||||||
|
let mut command = Command::new("/usr/bin/security");
|
||||||
|
command.args(["add-trusted-cert", "-r", "trustRoot", "-k"]);
|
||||||
|
command.arg(&keychain_path);
|
||||||
|
|
||||||
|
let output = command.arg(&temp_path).output().map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"Failed to launch macOS security tool to trust the local CA certificate: {e}"
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&temp_path);
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let detail = if stderr.is_empty() {
|
||||||
|
format!("security exited with status {}", output.status)
|
||||||
|
} else {
|
||||||
|
stderr
|
||||||
|
};
|
||||||
|
return Err(format!(
|
||||||
|
"Failed to add the local CodeNomad CA certificate to the macOS trust settings: {detail}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !macos_cert_is_trusted(cert_der)? {
|
||||||
|
return Err(format!(
|
||||||
|
"Added the local CodeNomad CA certificate to {} but could not verify that macOS trusts it",
|
||||||
|
keychain_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
write_trusted_marker(cert_der)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn windows_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
|
||||||
|
use windows_sys::Win32::Security::Cryptography::{
|
||||||
|
CertCloseStore, CertEnumCertificatesInStore, CertOpenSystemStoreW,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 mut context = CertEnumCertificatesInStore(store, std::ptr::null());
|
||||||
|
while !context.is_null() {
|
||||||
|
let encoded = std::slice::from_raw_parts(
|
||||||
|
(*context).pbCertEncoded,
|
||||||
|
(*context).cbCertEncoded as usize,
|
||||||
|
);
|
||||||
|
if encoded == cert_der {
|
||||||
|
CertCloseStore(store, 0);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
context = CertEnumCertificatesInStore(store, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
CertCloseStore(store, 0);
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn resolve_macos_user_keychain() -> Result<PathBuf, String> {
|
||||||
|
let output = std::process::Command::new("/usr/bin/security")
|
||||||
|
.args(["default-keychain", "-d", "user"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to resolve macOS default user keychain: {e}"))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let trimmed = stdout.trim().trim_matches('"');
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Ok(PathBuf::from(trimmed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let home = dirs::home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from));
|
||||||
|
let home = home.ok_or_else(|| "Cannot determine home directory for macOS keychain lookup".to_string())?;
|
||||||
|
Ok(home.join("Library/Keychains/login.keychain-db"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn macos_cert_is_trusted(cert_der: &[u8]) -> Result<bool, String> {
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
let temp_path = env::temp_dir().join(format!(
|
||||||
|
"codenomad-server-ca-verify-{}.cer",
|
||||||
|
trusted_marker_file_suffix(cert_der)
|
||||||
|
));
|
||||||
|
fs::write(&temp_path, cert_der)
|
||||||
|
.map_err(|e| format!("Failed to write temporary certificate {}: {e}", temp_path.display()))?;
|
||||||
|
|
||||||
|
let keychain_path = resolve_macos_user_keychain()?;
|
||||||
|
let fingerprint = macos_cert_sha256(&temp_path)?;
|
||||||
|
let find_output = Command::new("/usr/bin/security")
|
||||||
|
.args(["find-certificate", "-a", "-Z", "-c", "CodeNomad Local CA"])
|
||||||
|
.arg(&keychain_path)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to query macOS keychain certificates: {e}"))?;
|
||||||
|
|
||||||
|
if !find_output.status.success() {
|
||||||
|
let _ = fs::remove_file(&temp_path);
|
||||||
|
let stderr = String::from_utf8_lossy(&find_output.stderr).trim().to_string();
|
||||||
|
let detail = if stderr.is_empty() {
|
||||||
|
format!("security exited with status {}", find_output.status)
|
||||||
|
} else {
|
||||||
|
stderr
|
||||||
|
};
|
||||||
|
return Err(format!(
|
||||||
|
"Failed to inspect the macOS keychain for the local CodeNomad CA certificate: {detail}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&find_output.stdout);
|
||||||
|
if !stdout.to_ascii_uppercase().contains(&fingerprint) {
|
||||||
|
let _ = fs::remove_file(&temp_path);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let verify_output = Command::new("/usr/bin/security")
|
||||||
|
.args(["verify-cert", "-q", "-L", "-l", "-p", "basic", "-c"])
|
||||||
|
.arg(&temp_path)
|
||||||
|
.args(["-k"])
|
||||||
|
.arg(&keychain_path)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to verify macOS trust for the local CodeNomad CA certificate: {e}"))?;
|
||||||
|
|
||||||
|
let _ = fs::remove_file(&temp_path);
|
||||||
|
Ok(verify_output.status.success())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn macos_cert_sha256(cert_path: &Path) -> Result<String, String> {
|
||||||
|
let output = std::process::Command::new("/usr/bin/shasum")
|
||||||
|
.args(["-a", "256"])
|
||||||
|
.arg(cert_path)
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("Failed to compute SHA-256 for {}: {e}", cert_path.display()))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||||
|
let detail = if stderr.is_empty() {
|
||||||
|
format!("shasum exited with status {}", output.status)
|
||||||
|
} else {
|
||||||
|
stderr
|
||||||
|
};
|
||||||
|
return Err(format!(
|
||||||
|
"Failed to compute SHA-256 for {}: {detail}",
|
||||||
|
cert_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let hash = stdout
|
||||||
|
.split_whitespace()
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| format!("Failed to parse SHA-256 output for {}", cert_path.display()))?;
|
||||||
|
Ok(hash.to_ascii_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(not(windows), not(target_os = "macos")))]
|
||||||
|
pub fn needs_trust_in_store(_cert_der: &[u8]) -> Result<bool, String> {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(not(windows), not(target_os = "macos")))]
|
||||||
pub fn trust_cert_in_store(_cert_der: &[u8]) -> Result<(), String> {
|
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.
|
// Non-Windows platforms use native webview-specific handling instead of OS trust-store writes.
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ use windows_sys::Win32::System::JobObjects::{
|
|||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
const MISSING_NODE_PREFIX: &str = "CODENOMAD_MISSING_NODE:";
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -630,6 +631,13 @@ impl CliProcessManager {
|
|||||||
|
|
||||||
let use_user_shell = supports_user_shell();
|
let use_user_shell = supports_user_shell();
|
||||||
|
|
||||||
|
if !use_user_shell && which::which(&resolution.node_binary).is_err() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Node binary '{}' not found. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
||||||
|
resolution.node_binary
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let command_info = if use_user_shell {
|
let command_info = if use_user_shell {
|
||||||
log_line("spawning via user shell");
|
log_line("spawning via user shell");
|
||||||
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
ShellCommandType::UserShell(build_shell_command_string(&resolution, &args)?)
|
||||||
@@ -641,14 +649,6 @@ impl CliProcessManager {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
if !use_user_shell {
|
|
||||||
if which::which(&resolution.node_binary).is_err() {
|
|
||||||
return Err(anyhow::anyhow!(
|
|
||||||
"Node binary not found. Make sure Node.js is installed."
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let child = match &command_info {
|
let child = match &command_info {
|
||||||
ShellCommandType::UserShell(cmd) => {
|
ShellCommandType::UserShell(cmd) => {
|
||||||
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
log_line(&format!("spawn command: {} {:?}", cmd.shell, cmd.args));
|
||||||
@@ -920,6 +920,17 @@ impl CliProcessManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(node_binary) = line.strip_prefix(MISSING_NODE_PREFIX) {
|
||||||
|
let mut locked = status.lock();
|
||||||
|
if locked.error.is_none() {
|
||||||
|
locked.error = Some(format!(
|
||||||
|
"Node binary '{}' not found in the desktop shell environment. CodeNomad desktop currently requires Node.js installed on the system, or set NODE_BINARY to a valid runtime path.",
|
||||||
|
node_binary.trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(url) = local_url_regex
|
if let Some(url) = local_url_regex
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
@@ -1083,7 +1094,8 @@ impl CliEntry {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if dev {
|
if dev {
|
||||||
// Dev: plain HTTP + Vite dev server proxy.
|
// Dev: keep loopback HTTP for the Vite proxy, but also enable HTTPS so
|
||||||
|
// remote proxy sessions can still spin up secure local windows.
|
||||||
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
let ui_dev_server = std::env::var("VITE_DEV_SERVER_URL")
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
@@ -1100,7 +1112,7 @@ impl CliEntry {
|
|||||||
.unwrap_or_else(|| "info".to_string());
|
.unwrap_or_else(|| "info".to_string());
|
||||||
|
|
||||||
args.push("--https".to_string());
|
args.push("--https".to_string());
|
||||||
args.push("false".to_string());
|
args.push("true".to_string());
|
||||||
args.push("--http".to_string());
|
args.push("--http".to_string());
|
||||||
args.push("true".to_string());
|
args.push("true".to_string());
|
||||||
args.push("--http-port".to_string());
|
args.push("--http-port".to_string());
|
||||||
@@ -1248,7 +1260,13 @@ fn build_shell_command_string(
|
|||||||
for arg in entry.runner_args(cli_args) {
|
for arg in entry.runner_args(cli_args) {
|
||||||
quoted.push(shell_escape(&arg));
|
quoted.push(shell_escape(&arg));
|
||||||
}
|
}
|
||||||
let command = format!("ELECTRON_RUN_AS_NODE=1 exec {}", quoted.join(" "));
|
let command = format!(
|
||||||
|
"if command -v {} >/dev/null 2>&1; then ELECTRON_RUN_AS_NODE=1 exec {}; else printf '%s%s\\n' '{}' {} >&2; exit 127; fi",
|
||||||
|
shell_escape(&entry.node_binary),
|
||||||
|
quoted.join(" "),
|
||||||
|
MISSING_NODE_PREFIX,
|
||||||
|
shell_escape(&entry.node_binary),
|
||||||
|
);
|
||||||
let args = build_shell_args(&shell, &command);
|
let args = build_shell_args(&shell, &command);
|
||||||
log_line(&format!("user shell command: {} {:?}", shell, args));
|
log_line(&format!("user shell command: {} {:?}", shell, args));
|
||||||
Ok(ShellCommand { shell, args })
|
Ok(ShellCommand { shell, args })
|
||||||
@@ -1288,8 +1306,11 @@ fn build_shell_args(shell: &str, command: &str) -> Vec<String> {
|
|||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_lowercase();
|
.to_lowercase();
|
||||||
|
|
||||||
let _ = shell_name;
|
if shell_name.contains("zsh") || shell_name.contains("bash") {
|
||||||
vec!["-l".into(), "-c".into(), command.into()]
|
vec!["-i".into(), "-l".into(), "-c".into(), command.into()]
|
||||||
|
} else {
|
||||||
|
vec!["-l".into(), "-c".into(), command.into()]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
fn first_existing(paths: Vec<Option<PathBuf>>) -> Option<String> {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use tauri::{AppHandle, Manager, WebviewWindow};
|
|||||||
use url::Url;
|
use url::Url;
|
||||||
use webkit2gtk::{WebContextExt, WebView, WebViewExt};
|
use webkit2gtk::{WebContextExt, WebView, WebViewExt};
|
||||||
|
|
||||||
pub fn should_bootstrap_tls_navigation(target_url: &Url, skip_tls_verify: bool) -> bool {
|
pub fn should_bootstrap_tls_navigation(target_url: &Url, allow_tls_certificate: bool) -> bool {
|
||||||
skip_tls_verify && target_url.scheme() == "https"
|
allow_tls_certificate && target_url.scheme() == "https"
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn ensure_remote_window_tls_handler(
|
pub fn ensure_remote_window_tls_handler(
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use tauri::webview::Webview;
|
|||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
|
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
|
||||||
};
|
};
|
||||||
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||||
use tauri_plugin_global_shortcut::{
|
use tauri_plugin_global_shortcut::{
|
||||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||||
};
|
};
|
||||||
@@ -49,6 +50,7 @@ pub struct AppState {
|
|||||||
pub wake_lock: Mutex<Option<KeepAwake>>,
|
pub wake_lock: Mutex<Option<KeepAwake>>,
|
||||||
pub zoom_level: Mutex<f64>,
|
pub zoom_level: Mutex<f64>,
|
||||||
pub remote_origins: Mutex<HashMap<String, String>>,
|
pub remote_origins: Mutex<HashMap<String, String>>,
|
||||||
|
pub remote_proxy_sessions: Mutex<HashMap<String, String>>,
|
||||||
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
|
pub remote_skip_tls_verify: Mutex<HashMap<String, bool>>,
|
||||||
pub remote_tls_handlers: Mutex<HashSet<String>>,
|
pub remote_tls_handlers: Mutex<HashSet<String>>,
|
||||||
}
|
}
|
||||||
@@ -60,10 +62,86 @@ struct RemoteWindowPayload {
|
|||||||
name: String,
|
name: String,
|
||||||
base_url: String,
|
base_url: String,
|
||||||
entry_url: Option<String>,
|
entry_url: Option<String>,
|
||||||
|
proxy_session_id: Option<String>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
skip_tls_verify: bool,
|
skip_tls_verify: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn schedule_remote_proxy_session_cleanup(app: AppHandle, session_id: String) {
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(err) = cleanup_remote_proxy_session(&app, &session_id).await {
|
||||||
|
eprintln!(
|
||||||
|
"[tauri] failed to clean up remote proxy session {}: {}",
|
||||||
|
session_id, err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn confirm_local_certificate_install(app: &AppHandle) -> Result<bool, String> {
|
||||||
|
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
|
||||||
|
|
||||||
|
let mut dialog = app
|
||||||
|
.dialog()
|
||||||
|
.message(
|
||||||
|
"CodeNomad needs to install a local certificate to open self-signed HTTPS remote windows. This certificate is only used for local desktop proxy traffic on your machine. Your operating system may show a second certificate prompt after this.",
|
||||||
|
)
|
||||||
|
.title("Install Local Certificate")
|
||||||
|
.kind(MessageDialogKind::Warning)
|
||||||
|
.buttons(MessageDialogButtons::OkCancelCustom(
|
||||||
|
"Continue".into(),
|
||||||
|
"Cancel".into(),
|
||||||
|
));
|
||||||
|
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
dialog = dialog.parent(&window);
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.show(move |accepted| {
|
||||||
|
let _ = sender.send(accepted);
|
||||||
|
});
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn_blocking(move || receiver.recv().unwrap_or(false))
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_remote_proxy_session(app: &AppHandle, session_id: &str) -> Result<(), String> {
|
||||||
|
let status = app.state::<AppState>().manager.status();
|
||||||
|
let Some(base_url) = status.url else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut cleanup_url = Url::parse(&base_url).map_err(|err| err.to_string())?;
|
||||||
|
cleanup_url.set_path(&format!("/api/remote-proxy/sessions/{session_id}"));
|
||||||
|
cleanup_url.set_query(None);
|
||||||
|
cleanup_url.set_fragment(None);
|
||||||
|
|
||||||
|
let client = if cleanup_url.scheme() == "https" {
|
||||||
|
let local_cert = cert_manager::ensure_local_cert()?;
|
||||||
|
let ca_cert = reqwest::Certificate::from_der(&local_cert.ca_cert_der)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.add_root_certificate(ca_cert)
|
||||||
|
.build()
|
||||||
|
.map_err(|err| err.to_string())?
|
||||||
|
} else {
|
||||||
|
reqwest::Client::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.delete(cleanup_url.as_str())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("unexpected status {}", response.status()))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
#[serde(default, rename_all = "camelCase")]
|
#[serde(default, rename_all = "camelCase")]
|
||||||
struct WakeLockConfig {
|
struct WakeLockConfig {
|
||||||
@@ -193,6 +271,9 @@ async fn open_remote_window_impl(
|
|||||||
|
|
||||||
let window_url = parsed.clone();
|
let window_url = parsed.clone();
|
||||||
|
|
||||||
|
let allow_linux_tls_certificate =
|
||||||
|
parsed.scheme() == "https" && (payload.proxy_session_id.is_some() || payload.skip_tls_verify);
|
||||||
|
|
||||||
app.state::<AppState>()
|
app.state::<AppState>()
|
||||||
.remote_origins
|
.remote_origins
|
||||||
.lock()
|
.lock()
|
||||||
@@ -202,7 +283,25 @@ async fn open_remote_window_impl(
|
|||||||
.remote_skip_tls_verify
|
.remote_skip_tls_verify
|
||||||
.lock()
|
.lock()
|
||||||
.map_err(|err| err.to_string())?
|
.map_err(|err| err.to_string())?
|
||||||
.insert(label.clone(), parsed.scheme() == "https");
|
.insert(label.clone(), allow_linux_tls_certificate);
|
||||||
|
|
||||||
|
let replaced_session = {
|
||||||
|
let state = app.state::<AppState>();
|
||||||
|
let mut sessions = state
|
||||||
|
.remote_proxy_sessions
|
||||||
|
.lock()
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
match payload.proxy_session_id.clone() {
|
||||||
|
Some(session_id) => sessions.insert(label.clone(), session_id),
|
||||||
|
None => sessions.remove(&label),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(previous) = replaced_session {
|
||||||
|
if payload.proxy_session_id.as_deref() != Some(previous.as_str()) {
|
||||||
|
schedule_remote_proxy_session_cleanup(app.clone(), previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(existing) = app.get_webview_window(&label) {
|
if let Some(existing) = app.get_webview_window(&label) {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
@@ -217,8 +316,10 @@ async fn open_remote_window_impl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let initial_url = if linux_tls::should_bootstrap_tls_navigation(&window_url, payload.skip_tls_verify)
|
let initial_url = if linux_tls::should_bootstrap_tls_navigation(
|
||||||
{
|
&window_url,
|
||||||
|
allow_linux_tls_certificate,
|
||||||
|
) {
|
||||||
Url::parse("about:blank").map_err(|err| err.to_string())?
|
Url::parse("about:blank").map_err(|err| err.to_string())?
|
||||||
} else {
|
} else {
|
||||||
window_url.clone()
|
window_url.clone()
|
||||||
@@ -249,6 +350,11 @@ async fn open_remote_window_impl(
|
|||||||
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
if let Ok(mut origins) = app_handle.state::<AppState>().remote_origins.lock() {
|
||||||
origins.remove(&label_for_cleanup);
|
origins.remove(&label_for_cleanup);
|
||||||
}
|
}
|
||||||
|
if let Ok(mut sessions) = app_handle.state::<AppState>().remote_proxy_sessions.lock() {
|
||||||
|
if let Some(session_id) = sessions.remove(&label_for_cleanup) {
|
||||||
|
schedule_remote_proxy_session_cleanup(app_handle.clone(), session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
|
if let Ok(mut values) = app_handle.state::<AppState>().remote_skip_tls_verify.lock() {
|
||||||
values.remove(&label_for_cleanup);
|
values.remove(&label_for_cleanup);
|
||||||
}
|
}
|
||||||
@@ -267,12 +373,23 @@ async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Res
|
|||||||
{
|
{
|
||||||
let entry_url = payload.entry_url.as_deref().unwrap_or(payload.base_url.as_str());
|
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())?;
|
let parsed = Url::parse(entry_url).map_err(|err| err.to_string())?;
|
||||||
if parsed.scheme() == "https" {
|
if payload.proxy_session_id.is_some() && parsed.scheme() == "https" {
|
||||||
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
|
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
|
"Failed to load the local HTTPS certificate for the remote proxy window: {err}"
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
if cert_manager::needs_trust_in_store(&local_cert.ca_cert_der).map_err(|err| {
|
||||||
|
format!("Failed to inspect the local CodeNomad certificate trust state: {err}")
|
||||||
|
})? {
|
||||||
|
let accepted = confirm_local_certificate_install(&app).await?;
|
||||||
|
if !accepted {
|
||||||
|
return Err(
|
||||||
|
"CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
|
if let Err(err) = cert_manager::trust_cert_in_store(&local_cert.ca_cert_der) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
|
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
|
||||||
@@ -440,6 +557,7 @@ fn main() {
|
|||||||
wake_lock: Mutex::new(None),
|
wake_lock: Mutex::new(None),
|
||||||
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
|
||||||
remote_origins: Mutex::new(HashMap::new()),
|
remote_origins: Mutex::new(HashMap::new()),
|
||||||
|
remote_proxy_sessions: Mutex::new(HashMap::new()),
|
||||||
remote_skip_tls_verify: Mutex::new(HashMap::new()),
|
remote_skip_tls_verify: Mutex::new(HashMap::new()),
|
||||||
remote_tls_handlers: Mutex::new(HashSet::new()),
|
remote_tls_handlers: Mutex::new(HashSet::new()),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"frontendDist": "resources/ui-loading"
|
"frontendDist": "resources/ui-loading"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
"enableGTKAppId": true,
|
||||||
"withGlobalTauri": true,
|
"withGlobalTauri": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
@@ -41,6 +42,35 @@
|
|||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
"active": true,
|
"active": true,
|
||||||
|
"linux": {
|
||||||
|
"appimage": {
|
||||||
|
"files": {
|
||||||
|
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deb": {
|
||||||
|
"files": {
|
||||||
|
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
|
||||||
|
"/usr/share/icons/hicolor/32x32/apps/codenomad-tauri.png": "icons/linux/32x32.png",
|
||||||
|
"/usr/share/icons/hicolor/48x48/apps/codenomad-tauri.png": "icons/linux/48x48.png",
|
||||||
|
"/usr/share/icons/hicolor/64x64/apps/codenomad-tauri.png": "icons/linux/64x64.png",
|
||||||
|
"/usr/share/icons/hicolor/128x128/apps/codenomad-tauri.png": "icons/linux/128x128.png",
|
||||||
|
"/usr/share/icons/hicolor/256x256/apps/codenomad-tauri.png": "icons/linux/256x256.png",
|
||||||
|
"/usr/share/icons/hicolor/512x512/apps/codenomad-tauri.png": "icons/linux/512x512.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rpm": {
|
||||||
|
"files": {
|
||||||
|
"/usr/share/applications/ai.neuralnomads.codenomad.client.desktop": "icons/linux/ai.neuralnomads.codenomad.client.desktop",
|
||||||
|
"/usr/share/icons/hicolor/32x32/apps/codenomad-tauri.png": "icons/linux/32x32.png",
|
||||||
|
"/usr/share/icons/hicolor/48x48/apps/codenomad-tauri.png": "icons/linux/48x48.png",
|
||||||
|
"/usr/share/icons/hicolor/64x64/apps/codenomad-tauri.png": "icons/linux/64x64.png",
|
||||||
|
"/usr/share/icons/hicolor/128x128/apps/codenomad-tauri.png": "icons/linux/128x128.png",
|
||||||
|
"/usr/share/icons/hicolor/256x256/apps/codenomad-tauri.png": "icons/linux/256x256.png",
|
||||||
|
"/usr/share/icons/hicolor/512x512/apps/codenomad-tauri.png": "icons/linux/512x512.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"resources": [
|
"resources": [
|
||||||
"resources/server",
|
"resources/server",
|
||||||
"resources/ui-loading"
|
"resources/ui-loading"
|
||||||
|
|||||||
@@ -19,12 +19,60 @@ interface MonacoDiffViewerProps {
|
|||||||
insertContextLabel?: string
|
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) {
|
export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
||||||
const { isDark } = useTheme()
|
const { isDark } = useTheme()
|
||||||
let host: HTMLDivElement | undefined
|
let host: HTMLDivElement | undefined
|
||||||
|
|
||||||
let diffEditor: any = null
|
let diffEditor: any = null
|
||||||
let monaco: any = null
|
let monaco: any = null
|
||||||
|
let splitLayoutFrame: number | null = null
|
||||||
const [ready, setReady] = createSignal(false)
|
const [ready, setReady] = createSignal(false)
|
||||||
const [hoveredLine, setHoveredLine] = createSignal<number | null>(null)
|
const [hoveredLine, setHoveredLine] = createSignal<number | null>(null)
|
||||||
const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null)
|
const [selectedRange, setSelectedRange] = createSignal<{ startLine: number; endLine: number } | null>(null)
|
||||||
@@ -55,6 +103,44 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
diffEditor = null
|
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 getModifiedEditor = () => diffEditor?.getModifiedEditor?.() ?? null
|
||||||
|
|
||||||
const getActiveInsertRange = () => {
|
const getActiveInsertRange = () => {
|
||||||
@@ -120,7 +206,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
renderWhitespace: "selection",
|
renderWhitespace: "selection",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
wordWrap: props.wordWrap === "on" ? "on" : "off",
|
||||||
glyphMargin: true,
|
glyphMargin: false,
|
||||||
folding: false,
|
folding: false,
|
||||||
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
// Keep enough gutter space so unified diffs don't overlap `+`/`-` markers.
|
||||||
lineNumbersMinChars: 4,
|
lineNumbersMinChars: 4,
|
||||||
@@ -139,6 +225,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
|
if (splitLayoutFrame !== null && typeof window !== "undefined") {
|
||||||
|
window.cancelAnimationFrame(splitLayoutFrame)
|
||||||
|
splitLayoutFrame = null
|
||||||
|
}
|
||||||
|
clearSplitLayoutVariables()
|
||||||
setReady(false)
|
setReady(false)
|
||||||
disposeEditor()
|
disposeEditor()
|
||||||
})
|
})
|
||||||
@@ -149,6 +240,11 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
monaco.editor.setTheme(isDark() ? "vs-dark" : "vs")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (!host) return
|
||||||
|
host.dataset.viewMode = props.viewMode === "split" ? "split" : "unified"
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready() || !monaco || !diffEditor) return
|
if (!ready() || !monaco || !diffEditor) return
|
||||||
const modifiedEditor = diffEditor.getModifiedEditor?.()
|
const modifiedEditor = diffEditor.getModifiedEditor?.()
|
||||||
@@ -222,10 +318,23 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
const viewMode = props.viewMode === "unified" ? "unified" : "split"
|
||||||
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
const contextMode = props.contextMode === "collapsed" ? "collapsed" : "expanded"
|
||||||
const wordWrap = props.wordWrap === "on" ? "on" : "off"
|
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({
|
diffEditor.updateOptions({
|
||||||
renderSideBySide: viewMode === "split",
|
renderSideBySide: viewMode === "split",
|
||||||
renderSideBySideInlineBreakpoint: 0,
|
renderSideBySideInlineBreakpoint: 0,
|
||||||
|
renderIndicators: true,
|
||||||
|
lineNumbersMinChars: diffEditorLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
hideUnchangedRegions:
|
hideUnchangedRegions:
|
||||||
contextMode === "collapsed"
|
contextMode === "collapsed"
|
||||||
? { enabled: true }
|
? { enabled: true }
|
||||||
@@ -234,16 +343,30 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
diffEditor.getOriginalEditor?.()?.updateOptions?.({ wordWrap })
|
diffEditor.getOriginalEditor?.()?.updateOptions?.({
|
||||||
|
wordWrap,
|
||||||
|
lineNumbersMinChars: originalLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
diffEditor.getModifiedEditor?.()?.updateOptions?.({ wordWrap })
|
diffEditor.getModifiedEditor?.()?.updateOptions?.({
|
||||||
|
wordWrap,
|
||||||
|
lineNumbersMinChars: modifiedLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncSplitLayoutVariables({
|
||||||
|
viewMode,
|
||||||
|
originalLineNumbersMinChars,
|
||||||
|
lineDecorationsWidth,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|||||||
@@ -333,15 +333,23 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (openWindow) {
|
if (openWindow) {
|
||||||
const windowUrl =
|
const remoteProxySession =
|
||||||
runtimeEnv.host === "tauri"
|
runtimeEnv.host === "tauri" && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
|
||||||
? (await serverApi.createRemoteProxySession({
|
? await serverApi.createRemoteProxySession({
|
||||||
baseUrl: profile.baseUrl,
|
baseUrl: profile.baseUrl,
|
||||||
skipTlsVerify: profile.skipTlsVerify,
|
skipTlsVerify: profile.skipTlsVerify,
|
||||||
})).windowUrl
|
})
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
await openRemoteServerWindow(profile, windowUrl)
|
try {
|
||||||
|
await openRemoteServerWindow(profile, remoteProxySession?.windowUrl, remoteProxySession?.sessionId)
|
||||||
|
} catch (error) {
|
||||||
|
if (remoteProxySession) {
|
||||||
|
void serverApi.deleteRemoteProxySession(remoteProxySession.sessionId).catch(() => {})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
await markRemoteServerConnected(profile.id)
|
await markRemoteServerConnected(profile.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -357,7 +357,11 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
|
|||||||
const pill = activeSessionStatusPill()
|
const pill = activeSessionStatusPill()
|
||||||
if (!pill) return null
|
if (!pill) return null
|
||||||
return (
|
return (
|
||||||
<span class={`status-indicator session-status session-status-list ${pill.className}`} title={pill.title}>
|
<span
|
||||||
|
class={`status-indicator session-status session-status-list ${pill.className} notranslate`}
|
||||||
|
title={pill.title}
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
{pill.showAlertIcon ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||||
{pill.text}
|
{pill.text}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ const GitChangesTab: Component<GitChangesTabProps> = (props) => {
|
|||||||
onContextModeChange={props.onContextModeChange}
|
onContextModeChange={props.onContextModeChange}
|
||||||
onWordWrapModeChange={props.onWordWrapModeChange}
|
onWordWrapModeChange={props.onWordWrapModeChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
|
list={{ panel: renderGroupedList, overlay: renderGroupedList }}
|
||||||
|
|||||||
@@ -638,18 +638,25 @@ export default function MessageSection(props: MessageSectionProps) {
|
|||||||
const autoPinHoldTargetKey = createMemo(() => {
|
const autoPinHoldTargetKey = createMemo(() => {
|
||||||
if (!holdLongAssistantRepliesEnabled()) return null
|
if (!holdLongAssistantRepliesEnabled()) return null
|
||||||
const messageId = lastVisibleMessageId()
|
const messageId = lastVisibleMessageId()
|
||||||
return isAssistantTextMessage(messageId) ? messageId : null
|
return isStreamingAssistantTextMessage(messageId) ? messageId : null
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleHoldLongAssistantReplies() {
|
function toggleHoldLongAssistantReplies() {
|
||||||
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
|
updatePreferences({ holdLongAssistantReplies: !holdLongAssistantRepliesEnabled() })
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAssistantTextMessage(messageId: string | null | undefined) {
|
function isStreamingAssistantTextMessage(messageId: string | null | undefined) {
|
||||||
if (!messageId) return false
|
if (!messageId) return false
|
||||||
const resolvedStore = store()
|
const resolvedStore = store()
|
||||||
const record = resolvedStore.getMessage(messageId)
|
const record = resolvedStore.getMessage(messageId)
|
||||||
if (!record || record.role !== "assistant") return false
|
if (!record || record.role !== "assistant") return false
|
||||||
|
if (record.status !== "streaming") return false
|
||||||
|
|
||||||
|
const info = resolvedStore.getMessageInfo(messageId)
|
||||||
|
if (!info) return false
|
||||||
|
const timeInfo = info?.time as { end?: number } | undefined
|
||||||
|
const isStreaming = timeInfo?.end === undefined || timeInfo.end === 0
|
||||||
|
if (!isStreaming) return false
|
||||||
|
|
||||||
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
|
const { orderedParts } = buildRecordDisplayData(props.instanceId, record)
|
||||||
return orderedParts.some((part) => {
|
return orderedParts.some((part) => {
|
||||||
|
|||||||
@@ -581,113 +581,6 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
autocomplete="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()}>
|
<Show when={shouldShowOverlay()}>
|
||||||
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
<div class={`prompt-input-overlay keyboard-hints ${mode() === "shell" ? "shell-mode" : ""}`}>
|
||||||
<Show
|
<Show
|
||||||
@@ -742,6 +635,116 @@ export default function PromptInput(props: PromptInputProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="prompt-input-actions">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="stop-button"
|
class="stop-button"
|
||||||
|
|||||||
@@ -520,7 +520,11 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
<ChevronDown class={`w-3.5 h-3.5 transition-transform ${rowProps.expanded ? "" : "-rotate-90"}`} />
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</Show>
|
||||||
<span class={`status-indicator session-status session-status-list ${statusClassName()}`} title={statusTooltip()}>
|
<span
|
||||||
|
class={`status-indicator session-status session-status-list ${statusClassName()} notranslate`}
|
||||||
|
title={statusTooltip()}
|
||||||
|
translate="no"
|
||||||
|
>
|
||||||
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
{needsInput() ? <ShieldAlert class="w-3.5 h-3.5" aria-hidden="true" /> : <span class="status-dot" />}
|
||||||
{statusText()}
|
{statusText()}
|
||||||
</span>
|
</span>
|
||||||
@@ -736,7 +740,9 @@ const SessionList: Component<SessionListProps> = (props) => {
|
|||||||
<div class="session-list-header p-3 border-b border-base">
|
<div class="session-list-header p-3 border-b border-base">
|
||||||
{props.headerContent ?? (
|
{props.headerContent ?? (
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h3 class="text-sm font-semibold text-primary">{t("sessionList.header.title")}</h3>
|
<h3 class="text-sm font-semibold text-primary notranslate" translate="no">
|
||||||
|
{t("sessionList.header.title")}
|
||||||
|
</h3>
|
||||||
<KeyboardHint
|
<KeyboardHint
|
||||||
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
|
shortcuts={[keyboardRegistry.get("session-prev")!, keyboardRegistry.get("session-next")!].filter(Boolean)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
|
|||||||
|
|
||||||
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
const DEFAULT_SCROLL_SENTINEL_MARGIN_PX = 48
|
||||||
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
|
const DEFAULT_HOLD_TARGET_TOP_THRESHOLD_PX = 8
|
||||||
const DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX = 128
|
|
||||||
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
const USER_SCROLL_INTENT_WINDOW_MS = 600
|
||||||
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
const SCROLL_INTENT_KEYS = new Set(["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " ", "Spacebar"])
|
||||||
|
|
||||||
@@ -162,8 +161,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
const [showScrollTopButton, setShowScrollTopButton] = createSignal(false)
|
||||||
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
const [showScrollBottomButton, setShowScrollBottomButton] = createSignal(false)
|
||||||
const [activeKey, setActiveKey] = createSignal<string | null>(null)
|
const [activeKey, setActiveKey] = createSignal<string | null>(null)
|
||||||
const [heldItemCount, setHeldItemCount] = createSignal<number | null>(null)
|
const [activeHoldTargetKey, setActiveHoldTargetKey] = createSignal<string | null>(null)
|
||||||
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || heldItemCount() !== null
|
const [didTriggerHoldForCurrentTarget, setDidTriggerHoldForCurrentTarget] = createSignal(false)
|
||||||
|
const effectiveSuspendAutoPinToBottom = () => externalSuspendAutoPinToBottom() || activeHoldTargetKey() !== null
|
||||||
|
|
||||||
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
const scrollButtonsCount = createMemo(() => (showScrollTopButton() ? 1 : 0) + (showScrollBottomButton() ? 1 : 0))
|
||||||
const itemElements = new Map<string, HTMLDivElement>()
|
const itemElements = new Map<string, HTMLDivElement>()
|
||||||
@@ -197,6 +197,17 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
return performance.now() <= userScrollIntentUntil
|
return performance.now() <= userScrollIntentUntil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearAutoPinHold(options?: { resumeBottom?: boolean }) {
|
||||||
|
if (activeHoldTargetKey() === null) return
|
||||||
|
setActiveHoldTargetKey(null)
|
||||||
|
if (options?.resumeBottom && autoScroll()) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!autoScroll() || activeHoldTargetKey() !== null) return
|
||||||
|
scrollToBottom(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
function attachScrollIntentListeners(element: HTMLDivElement | undefined) {
|
||||||
if (detachScrollIntentListeners) {
|
if (detachScrollIntentListeners) {
|
||||||
detachScrollIntentListeners()
|
detachScrollIntentListeners()
|
||||||
@@ -258,7 +269,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
// scrollbar). If follow mode stays enabled, the next render notification
|
// scrollbar). If follow mode stays enabled, the next render notification
|
||||||
// snaps the list straight back to bottom. A real upward viewport move away
|
// snaps the list straight back to bottom. A real upward viewport move away
|
||||||
// from bottom should always break follow unless a hold target is active.
|
// from bottom should always break follow unless a hold target is active.
|
||||||
if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && heldItemCount() === null) {
|
if (wasPinnedAtBottom && scrolledUp && autoScroll() && !atBottom && activeHoldTargetKey() === null) {
|
||||||
setAutoScroll(false)
|
setAutoScroll(false)
|
||||||
lastObservedPinnedAtBottom = false
|
lastObservedPinnedAtBottom = false
|
||||||
return
|
return
|
||||||
@@ -266,9 +277,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
|
|
||||||
// Sync autoScroll state based on scroll position if it was a user scroll
|
// Sync autoScroll state based on scroll position if it was a user scroll
|
||||||
if (hasUserScrollIntent()) {
|
if (hasUserScrollIntent()) {
|
||||||
if (atBottom && heldItemCount() !== null) {
|
clearAutoPinHold()
|
||||||
setHeldItemCount(null)
|
|
||||||
}
|
|
||||||
if (atBottom && !autoScroll()) {
|
if (atBottom && !autoScroll()) {
|
||||||
setAutoScroll(true)
|
setAutoScroll(true)
|
||||||
} else if (!atBottom && autoScroll()) {
|
} else if (!atBottom && autoScroll()) {
|
||||||
@@ -304,7 +313,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateScrollButtons()
|
updateScrollButtons()
|
||||||
updateAutoPinHold()
|
|
||||||
props.onScroll?.()
|
props.onScroll?.()
|
||||||
|
|
||||||
// Find active key (roughly the first visible item)
|
// Find active key (roughly the first visible item)
|
||||||
@@ -336,25 +344,14 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
|
|
||||||
function updateAutoPinHold() {
|
function updateAutoPinHold() {
|
||||||
const element = scrollElement()
|
const element = scrollElement()
|
||||||
const itemCount = props.items().length
|
|
||||||
const heldCount = heldItemCount()
|
|
||||||
if (!element) return
|
if (!element) return
|
||||||
|
|
||||||
if (heldCount !== null) {
|
const targetKey = holdTargetKey()
|
||||||
if (itemCount > heldCount) {
|
const heldKey = activeHoldTargetKey()
|
||||||
setHeldItemCount(null)
|
|
||||||
if (autoScroll()) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!autoScroll()) return
|
|
||||||
scrollToBottom(false)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemCount < heldCount) {
|
if (heldKey !== null) {
|
||||||
setHeldItemCount(null)
|
if (targetKey !== heldKey) {
|
||||||
return
|
clearAutoPinHold({ resumeBottom: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
return
|
return
|
||||||
@@ -362,9 +359,8 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
|
|
||||||
if (!autoScroll()) return
|
if (!autoScroll()) return
|
||||||
if (externalSuspendAutoPinToBottom()) return
|
if (externalSuspendAutoPinToBottom()) return
|
||||||
|
|
||||||
const targetKey = holdTargetKey()
|
|
||||||
if (!targetKey) return
|
if (!targetKey) return
|
||||||
|
if (didTriggerHoldForCurrentTarget()) return
|
||||||
|
|
||||||
const itemWrapper = itemElements.get(targetKey)
|
const itemWrapper = itemElements.get(targetKey)
|
||||||
if (!itemWrapper) return
|
if (!itemWrapper) return
|
||||||
@@ -375,12 +371,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
const relativeTop = targetRect.top - containerRect.top
|
const relativeTop = targetRect.top - containerRect.top
|
||||||
const exceedsViewport = targetRect.height > element.clientHeight
|
const exceedsViewport = targetRect.height > element.clientHeight
|
||||||
|
|
||||||
if (
|
if (exceedsViewport && relativeTop < 0) {
|
||||||
exceedsViewport &&
|
const alignDelta = relativeTop - holdTargetTopThresholdPx()
|
||||||
relativeTop <= holdTargetTopThresholdPx() &&
|
if (Math.abs(alignDelta) > 1) {
|
||||||
relativeTop >= holdTargetTopThresholdPx() - DEFAULT_HOLD_TARGET_TOP_OVERSHOOT_PX
|
element.scrollTop = Math.max(0, element.scrollTop + alignDelta)
|
||||||
) {
|
}
|
||||||
setHeldItemCount(itemCount)
|
setActiveHoldTargetKey(targetKey)
|
||||||
|
setDidTriggerHoldForCurrentTarget(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +393,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
},
|
},
|
||||||
notifyContentRendered: () => {
|
notifyContentRendered: () => {
|
||||||
updateAutoPinHold()
|
updateAutoPinHold()
|
||||||
if (heldItemCount() !== null) return
|
if (activeHoldTargetKey() !== null) return
|
||||||
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
||||||
scrollToBottom(true)
|
scrollToBottom(true)
|
||||||
}
|
}
|
||||||
@@ -412,14 +409,23 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
|
|
||||||
createEffect(on(() => props.resetKey?.(), () => {
|
createEffect(on(() => props.resetKey?.(), () => {
|
||||||
itemElements.clear()
|
itemElements.clear()
|
||||||
setHeldItemCount(null)
|
setActiveHoldTargetKey(null)
|
||||||
|
setDidTriggerHoldForCurrentTarget(false)
|
||||||
lastObservedScrollOffset = 0
|
lastObservedScrollOffset = 0
|
||||||
lastObservedPinnedAtBottom = false
|
lastObservedPinnedAtBottom = false
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
createEffect(on(holdTargetKey, (nextTargetKey, prevTargetKey) => {
|
||||||
|
if (nextTargetKey !== prevTargetKey && didTriggerHoldForCurrentTarget()) {
|
||||||
|
setDidTriggerHoldForCurrentTarget(false)
|
||||||
|
}
|
||||||
|
if (activeHoldTargetKey() === null) return
|
||||||
|
if (nextTargetKey === activeHoldTargetKey()) return
|
||||||
|
clearAutoPinHold({ resumeBottom: true })
|
||||||
|
}, { defer: true }))
|
||||||
|
|
||||||
// Handle autoScroll (Follow) on items change
|
// Handle autoScroll (Follow) on items change
|
||||||
createEffect(on(() => props.items().length, (len, prevLen) => {
|
createEffect(on(() => props.items().length, (len, prevLen) => {
|
||||||
updateAutoPinHold()
|
|
||||||
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
|
if (len > (prevLen ?? 0) && autoScroll() && !effectiveSuspendAutoPinToBottom() && !suppressAutoScrollOnce) {
|
||||||
requestAnimationFrame(() => scrollToBottom(true))
|
requestAnimationFrame(() => scrollToBottom(true))
|
||||||
}
|
}
|
||||||
@@ -428,16 +434,11 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
|
|
||||||
// Handle followToken change
|
// Handle followToken change
|
||||||
createEffect(on(() => props.followToken?.(), () => {
|
createEffect(on(() => props.followToken?.(), () => {
|
||||||
updateAutoPinHold()
|
|
||||||
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
if (autoScroll() && !effectiveSuspendAutoPinToBottom()) {
|
||||||
scrollToBottom(true)
|
scrollToBottom(true)
|
||||||
}
|
}
|
||||||
}, { defer: true }))
|
}, { defer: true }))
|
||||||
|
|
||||||
createEffect(on(() => holdTargetKey(), () => {
|
|
||||||
updateAutoPinHold()
|
|
||||||
}, { defer: true }))
|
|
||||||
|
|
||||||
// Reset state on resetKey change
|
// Reset state on resetKey change
|
||||||
createEffect(on(() => props.resetKey?.(), (nextKey) => {
|
createEffect(on(() => props.resetKey?.(), (nextKey) => {
|
||||||
if (nextKey === lastResetKey) return
|
if (nextKey === lastResetKey) return
|
||||||
@@ -460,13 +461,6 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
const handleResize = () => updateAutoPinHold()
|
|
||||||
window.addEventListener("resize", handleResize)
|
|
||||||
onCleanup(() => window.removeEventListener("resize", handleResize))
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="virtual-follow-list-shell" ref={shellElement => {
|
<div class="virtual-follow-list-shell" ref={shellElement => {
|
||||||
setShellElement(shellElement)
|
setShellElement(shellElement)
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ type WorktreeOption =
|
|||||||
| { kind: "action"; key: "__create__"; label: string }
|
| { kind: "action"; key: "__create__"; label: string }
|
||||||
| { kind: "worktree"; key: string; slug: string; directory: string; raw: WorktreeDescriptor }
|
| { 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) {
|
function preventSelectPress(event: PointerEvent | MouseEvent) {
|
||||||
// Prevent Select.Item from treating this as a selection.
|
// Prevent Select.Item from treating this as a selection.
|
||||||
// We intentionally prevent default to stop Kobalte's internal press handling.
|
// 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("/") || "."
|
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 {
|
interface WorktreeSelectorProps {
|
||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
@@ -80,6 +139,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
|
const [deleteTarget, setDeleteTarget] = createSignal<WorktreeOption & { kind: "worktree" } | null>(null)
|
||||||
const [forceDelete, setForceDelete] = createSignal(false)
|
const [forceDelete, setForceDelete] = createSignal(false)
|
||||||
const [isDeleting, setIsDeleting] = 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 session = createMemo(() => sessions().get(props.instanceId)?.get(props.sessionId))
|
||||||
const isChildSession = createMemo(() => Boolean(session()?.parentId))
|
const isChildSession = createMemo(() => Boolean(session()?.parentId))
|
||||||
@@ -114,10 +174,16 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
|
const openDeleteDialog = (opt: WorktreeOption & { kind: "worktree" }) => {
|
||||||
if (opt.slug === "root") return
|
if (opt.slug === "root") return
|
||||||
setForceDelete(false)
|
setForceDelete(false)
|
||||||
|
setDeleteError(null)
|
||||||
setDeleteTarget(opt)
|
setDeleteTarget(opt)
|
||||||
setDeleteOpen(true)
|
setDeleteOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeDeleteDialog = () => {
|
||||||
|
setDeleteOpen(false)
|
||||||
|
setDeleteError(null)
|
||||||
|
}
|
||||||
|
|
||||||
const repoRoot = createMemo(() => {
|
const repoRoot = createMemo(() => {
|
||||||
const list = getWorktrees(props.instanceId)
|
const list = getWorktrees(props.instanceId)
|
||||||
return list.find((wt) => wt.slug === "root")?.directory ?? ""
|
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) => {
|
const handleChange = async (value: WorktreeOption | null) => {
|
||||||
if (worktreesUnavailable()) return
|
if (worktreesUnavailable()) return
|
||||||
if (!value) return
|
if (!value) return
|
||||||
@@ -343,22 +492,23 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && setDeleteOpen(false)}>
|
<Dialog open={deleteOpen()} onOpenChange={(open) => !open && closeDeleteDialog()}>
|
||||||
<Dialog.Portal>
|
<Dialog.Portal>
|
||||||
<Dialog.Overlay class="modal-overlay" />
|
<Dialog.Overlay class="modal-overlay" />
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-3 md:p-4">
|
||||||
<Dialog.Content class="modal-surface w-full max-w-md p-6 flex flex-col gap-5">
|
<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>
|
<div>
|
||||||
<Dialog.Title class="text-xl font-semibold text-primary">Delete worktree</Dialog.Title>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Show when={deleteTarget()}>
|
<Show when={deleteTarget()}>
|
||||||
{(target) => (
|
{(target) => (
|
||||||
<div class="rounded-lg border border-base bg-surface-secondary p-4">
|
<div class="rounded-lg border border-base bg-surface-secondary px-3 py-2">
|
||||||
<p class="text-xs font-medium text-muted uppercase tracking-wide mb-1">Worktree</p>
|
<p class="text-sm text-primary">
|
||||||
<p class="text-sm font-mono text-primary break-all">{target().slug}</p>
|
Worktree <span class="font-semibold font-mono">"{target().slug}"</span>
|
||||||
<p class="text-[11px] text-secondary mt-2 break-all font-mono">{target().directory}</p>
|
</p>
|
||||||
|
<p class="text-[11px] text-secondary break-all font-mono leading-5">{target().directory}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
@@ -377,7 +527,7 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="selector-button selector-button-secondary"
|
class="selector-button selector-button-secondary"
|
||||||
onClick={() => setDeleteOpen(false)}
|
onClick={closeDeleteDialog}
|
||||||
disabled={isDeleting()}
|
disabled={isDeleting()}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -389,12 +539,13 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const target = deleteTarget()
|
const target = deleteTarget()
|
||||||
if (!target) {
|
if (!target) {
|
||||||
setDeleteOpen(false)
|
closeDeleteDialog()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setIsDeleting(true)
|
setIsDeleting(true)
|
||||||
|
setDeleteError(null)
|
||||||
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
|
await deleteWorktree(props.instanceId, target.slug, { force: forceDelete() })
|
||||||
await reloadWorktrees(props.instanceId)
|
await reloadWorktrees(props.instanceId)
|
||||||
await reloadWorktreeMap(props.instanceId)
|
await reloadWorktreeMap(props.instanceId)
|
||||||
@@ -403,15 +554,12 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
|
await setWorktreeSlugForParentSession(props.instanceId, parentId(), "root")
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeleteOpen(false)
|
closeDeleteDialog()
|
||||||
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
|
showToastNotification({ message: `Deleted worktree ${target.slug}`, variant: "success" })
|
||||||
})()
|
})()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
log.warn("Failed to delete worktree", error)
|
log.warn("Failed to delete worktree", error)
|
||||||
showToastNotification({
|
setDeleteError(error instanceof Error ? error.message : t("instanceShell.worktree.delete.error.fallback"))
|
||||||
message: error instanceof Error ? error.message : "Failed to delete worktree",
|
|
||||||
variant: "error",
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setIsDeleting(false)
|
setIsDeleting(false)
|
||||||
@@ -421,6 +569,56 @@ export default function WorktreeSelector(props: WorktreeSelectorProps) {
|
|||||||
{isDeleting() ? "Deleting..." : "Delete"}
|
{isDeleting() ? "Deleting..." : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</Dialog.Content>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
|
|||||||
@@ -264,6 +264,9 @@ export const serverApi = {
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
deleteRemoteProxySession(id: string): Promise<void> {
|
||||||
|
return request(`/api/remote-proxy/sessions/${encodeURIComponent(id)}`, { method: "DELETE" })
|
||||||
|
},
|
||||||
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
fetchAuthStatus(): Promise<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }> {
|
||||||
return request<{ authenticated: boolean; username?: string; passwordUserProvided?: boolean }>("/api/auth/status")
|
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.enableWordWrap": "Enable word wrap",
|
||||||
"instanceShell.diff.disableWordWrap": "Disable word wrap",
|
"instanceShell.diff.disableWordWrap": "Disable word wrap",
|
||||||
"instanceShell.worktree.create": "+ Create worktree",
|
"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.noSessionSelected": "Select a session to view plan.",
|
||||||
"instanceShell.plan.empty": "Nothing planned yet.",
|
"instanceShell.plan.empty": "Nothing planned yet.",
|
||||||
|
|||||||
@@ -166,6 +166,30 @@ export const instanceMessages = {
|
|||||||
"instanceShell.backgroundProcesses.actions.output": "Salida",
|
"instanceShell.backgroundProcesses.actions.output": "Salida",
|
||||||
"instanceShell.backgroundProcesses.actions.stop": "Detener",
|
"instanceShell.backgroundProcesses.actions.stop": "Detener",
|
||||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminar",
|
"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.appWithVersion": "App {version}",
|
||||||
"versionPill.ui": "UI",
|
"versionPill.ui": "UI",
|
||||||
|
|||||||
@@ -166,6 +166,30 @@ export const instanceMessages = {
|
|||||||
"instanceShell.backgroundProcesses.actions.output": "Sortie",
|
"instanceShell.backgroundProcesses.actions.output": "Sortie",
|
||||||
"instanceShell.backgroundProcesses.actions.stop": "Arrêter",
|
"instanceShell.backgroundProcesses.actions.stop": "Arrêter",
|
||||||
"instanceShell.backgroundProcesses.actions.terminate": "Terminer",
|
"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.appWithVersion": "Appli {version}",
|
||||||
"versionPill.ui": "UI",
|
"versionPill.ui": "UI",
|
||||||
|
|||||||
@@ -174,6 +174,30 @@ export const instanceMessages = {
|
|||||||
"instanceShell.backgroundProcesses.actions.output": "פלט",
|
"instanceShell.backgroundProcesses.actions.output": "פלט",
|
||||||
"instanceShell.backgroundProcesses.actions.stop": "עצור",
|
"instanceShell.backgroundProcesses.actions.stop": "עצור",
|
||||||
"instanceShell.backgroundProcesses.actions.terminate": "סיים",
|
"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.appWithVersion": "אפליקציה {version}",
|
||||||
"versionPill.ui": "ממשק",
|
"versionPill.ui": "ממשק",
|
||||||
|
|||||||
@@ -166,6 +166,30 @@ export const instanceMessages = {
|
|||||||
"instanceShell.backgroundProcesses.actions.output": "出力",
|
"instanceShell.backgroundProcesses.actions.output": "出力",
|
||||||
"instanceShell.backgroundProcesses.actions.stop": "停止",
|
"instanceShell.backgroundProcesses.actions.stop": "停止",
|
||||||
"instanceShell.backgroundProcesses.actions.terminate": "終了",
|
"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.appWithVersion": "アプリ {version}",
|
||||||
"versionPill.ui": "UI",
|
"versionPill.ui": "UI",
|
||||||
|
|||||||
@@ -166,6 +166,30 @@ export const instanceMessages = {
|
|||||||
"instanceShell.backgroundProcesses.actions.output": "Вывод",
|
"instanceShell.backgroundProcesses.actions.output": "Вывод",
|
||||||
"instanceShell.backgroundProcesses.actions.stop": "Остановить",
|
"instanceShell.backgroundProcesses.actions.stop": "Остановить",
|
||||||
"instanceShell.backgroundProcesses.actions.terminate": "Завершить",
|
"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.appWithVersion": "Приложение {version}",
|
||||||
"versionPill.ui": "UI",
|
"versionPill.ui": "UI",
|
||||||
|
|||||||
@@ -166,6 +166,30 @@ export const instanceMessages = {
|
|||||||
"instanceShell.backgroundProcesses.actions.output": "输出",
|
"instanceShell.backgroundProcesses.actions.output": "输出",
|
||||||
"instanceShell.backgroundProcesses.actions.stop": "停止",
|
"instanceShell.backgroundProcesses.actions.stop": "停止",
|
||||||
"instanceShell.backgroundProcesses.actions.terminate": "终止",
|
"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.appWithVersion": "应用 {version}",
|
||||||
"versionPill.ui": "UI",
|
"versionPill.ui": "UI",
|
||||||
|
|||||||
@@ -7,18 +7,21 @@ export interface RemoteWindowOpenPayload {
|
|||||||
name: string
|
name: string
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
entryUrl?: string
|
entryUrl?: string
|
||||||
|
proxySessionId?: string
|
||||||
skipTlsVerify: boolean
|
skipTlsVerify: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openRemoteServerWindow(
|
export async function openRemoteServerWindow(
|
||||||
profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">,
|
profile: Pick<RemoteServerProfile, "id" | "name" | "baseUrl" | "skipTlsVerify">,
|
||||||
entryUrl?: string,
|
entryUrl?: string,
|
||||||
|
proxySessionId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const payload: RemoteWindowOpenPayload = {
|
const payload: RemoteWindowOpenPayload = {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
name: profile.name,
|
name: profile.name,
|
||||||
baseUrl: profile.baseUrl,
|
baseUrl: profile.baseUrl,
|
||||||
entryUrl,
|
entryUrl,
|
||||||
|
proxySessionId,
|
||||||
skipTlsVerify: profile.skipTlsVerify,
|
skipTlsVerify: profile.skipTlsVerify,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -397,7 +397,8 @@ function handleMessageUpdate(instanceId: string, event: MessageUpdateEvent | Mes
|
|||||||
|
|
||||||
const role: MessageRole = info.role === "user" ? "user" : "assistant"
|
const role: MessageRole = info.role === "user" ? "user" : "assistant"
|
||||||
const hasError = Boolean((info as any).error)
|
const hasError = Boolean((info as any).error)
|
||||||
const status: MessageStatus = hasError ? "error" : "complete"
|
const hasEnded = typeof timeInfo.end === "number" && timeInfo.end > 0
|
||||||
|
const status: MessageStatus = hasError ? "error" : hasEnded ? "complete" : "streaming"
|
||||||
|
|
||||||
let record = store.getMessage(messageId)
|
let record = store.getMessage(messageId)
|
||||||
if (!record) {
|
if (!record) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
.prompt-input-wrapper {
|
.prompt-input-wrapper {
|
||||||
@apply grid items-stretch;
|
@apply grid items-stretch;
|
||||||
grid-template-columns: minmax(0, 1fr) 64px;
|
grid-template-columns: minmax(0, 1fr) 72px 64px;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,16 @@
|
|||||||
gap: 0.5rem;
|
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 {
|
.prompt-input-field-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -37,7 +47,7 @@
|
|||||||
.prompt-input {
|
.prompt-input {
|
||||||
@apply w-full pt-2.5 border text-sm resize-none outline-none transition-colors;
|
@apply w-full pt-2.5 border text-sm resize-none outline-none transition-colors;
|
||||||
padding-inline-start: 0.75rem;
|
padding-inline-start: 0.75rem;
|
||||||
padding-inline-end: 7.5rem;
|
padding-inline-end: 0.75rem;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
background-color: var(--surface-base);
|
background-color: var(--surface-base);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -85,16 +95,12 @@
|
|||||||
|
|
||||||
/* Navigation buttons container (expand, prev, next). */
|
/* Navigation buttons container (expand, prev, next). */
|
||||||
.prompt-nav-buttons {
|
.prompt-nav-buttons {
|
||||||
position: absolute;
|
|
||||||
top: 0.25rem;
|
|
||||||
inset-inline-end: 0.25rem;
|
|
||||||
bottom: 0.25rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-end;
|
justify-content: center;
|
||||||
gap: 0.125rem;
|
gap: 0.125rem;
|
||||||
z-index: 2;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-nav-column {
|
.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;
|
@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);
|
background-color: var(--accent-primary);
|
||||||
color: var(--text-inverted);
|
color: var(--text-inverted);
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.send-button.shell-mode {
|
.send-button.shell-mode {
|
||||||
@@ -421,7 +426,7 @@
|
|||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.prompt-input-wrapper {
|
.prompt-input-wrapper {
|
||||||
grid-template-columns: minmax(0, 1fr) 40px;
|
grid-template-columns: minmax(0, 1fr) 64px 40px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +434,6 @@
|
|||||||
.prompt-input {
|
.prompt-input {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
padding-inline-end: 7.5rem;
|
|
||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -611,6 +611,40 @@
|
|||||||
z-index: 30;
|
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 {
|
.file-viewer-empty {
|
||||||
@apply flex flex-col items-center justify-center h-full gap-3 text-center;
|
@apply flex flex-col items-center justify-center h-full gap-3 text-center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
1
packages/ui/src/types/global.d.ts
vendored
1
packages/ui/src/types/global.d.ts
vendored
@@ -38,6 +38,7 @@ declare global {
|
|||||||
name: string
|
name: string
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
entryUrl?: string
|
entryUrl?: string
|
||||||
|
proxySessionId?: string
|
||||||
skipTlsVerify: boolean
|
skipTlsVerify: boolean
|
||||||
}) => Promise<{ ok: boolean }>
|
}) => Promise<{ ok: boolean }>
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user