Compare commits
5 Commits
v0.14.0-de
...
v0.14.0-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68551f6731 | ||
|
|
662a6b94b0 | ||
|
|
77df40169a | ||
|
|
3b411e2e73 | ||
|
|
016c7bda4a |
@@ -277,6 +277,7 @@ function createWindow() {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
additionalArguments: ["--codenomad-window-context=local"],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -440,6 +441,7 @@ async function openRemoteWindow(payload: { id: string; name: string; baseUrl: st
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
spellcheck: !isMac,
|
||||
additionalArguments: ["--codenomad-window-context=remote"],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
const { contextBridge, ipcRenderer, webUtils } = require("electron")
|
||||
|
||||
const electronAPI = {
|
||||
function resolveWindowContext() {
|
||||
const prefix = "--codenomad-window-context="
|
||||
const arg = process.argv.find((value) => typeof value === "string" && value.startsWith(prefix))
|
||||
const context = arg ? arg.slice(prefix.length) : "local"
|
||||
return context === "remote" ? "remote" : "local"
|
||||
}
|
||||
|
||||
function resolveRuntimeHost(windowContext) {
|
||||
return "electron"
|
||||
}
|
||||
|
||||
const windowContext = resolveWindowContext()
|
||||
|
||||
const localElectronAPI = {
|
||||
onCliStatus: (callback) => {
|
||||
ipcRenderer.on("cli:status", (_, data) => callback(data))
|
||||
return () => ipcRenderer.removeAllListeners("cli:status")
|
||||
@@ -26,4 +39,15 @@ const electronAPI = {
|
||||
openRemoteWindow: (payload) => ipcRenderer.invoke("remote:openWindow", payload),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", electronAPI)
|
||||
const remoteElectronAPI = {
|
||||
requestMicrophoneAccess: localElectronAPI.requestMicrophoneAccess,
|
||||
setWakeLock: localElectronAPI.setWakeLock,
|
||||
showNotification: localElectronAPI.showNotification,
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld(
|
||||
"electronAPI",
|
||||
windowContext === "local" ? localElectronAPI : remoteElectronAPI,
|
||||
)
|
||||
contextBridge.exposeInMainWorld("__CODENOMAD_WINDOW_CONTEXT__", windowContext)
|
||||
contextBridge.exposeInMainWorld("__CODENOMAD_RUNTIME_HOST__", resolveRuntimeHost(windowContext))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { probeBinaryVersion } from "../../workspaces/runtime"
|
||||
import { probeBinaryVersion } from "../../workspaces/spawn"
|
||||
import type { SettingsService } from "../../settings/service"
|
||||
import type { Logger } from "../../logger"
|
||||
import { sanitizeConfigDoc, sanitizeConfigOwner } from "../../settings/public-config"
|
||||
|
||||
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
193
packages/server/src/workspaces/__tests__/spawn.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
|
||||
import { buildWindowsSpawnSpec, buildWslSignalSpec, parseWslUncPath, resolveWslWorkingDirectory } from "../spawn"
|
||||
|
||||
describe("parseWslUncPath", () => {
|
||||
it("parses WSL UNC paths into distro and linux path", () => {
|
||||
assert.deepEqual(parseWslUncPath(String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`), {
|
||||
distro: "Ubuntu",
|
||||
linuxPath: "/home/dev/.opencode/bin/opencode",
|
||||
})
|
||||
})
|
||||
|
||||
it("supports the legacy wsl$ UNC prefix", () => {
|
||||
assert.deepEqual(parseWslUncPath(String.raw`\\wsl$\Ubuntu\home\dev`), {
|
||||
distro: "Ubuntu",
|
||||
linuxPath: "/home/dev",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveWslWorkingDirectory", () => {
|
||||
it("keeps WSL workspace folders in the same distro", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "linux", path: "/home/dev/workspace" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps Windows drive paths so WSL can resolve them with wslpath", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`C:\Users\dev\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "windows", path: String.raw`C:\Users\dev\workspace` }),
|
||||
)
|
||||
})
|
||||
|
||||
it("keeps UNC network paths so WSL can resolve them with wslpath", () => {
|
||||
assert.equal(
|
||||
JSON.stringify(resolveWslWorkingDirectory(String.raw`\\server\share\workspace`, "Ubuntu")),
|
||||
JSON.stringify({ kind: "windows", path: String.raw`\\server\share\workspace` }),
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects WSL workspace folders from a different distro", () => {
|
||||
assert.equal(resolveWslWorkingDirectory(String.raw`\\wsl.localhost\Debian\home\dev\workspace`, "Ubuntu"), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildWindowsSpawnSpec", () => {
|
||||
it("wraps WSL binaries with wsl.exe and propagates required env vars", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve", "--port", "0"],
|
||||
{
|
||||
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||
env: {
|
||||
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||
CODENOMAD_INSTANCE_ID: "workspace-123",
|
||||
OPENCODE_SERVER_PASSWORD: "secret",
|
||||
},
|
||||
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID", "OPENCODE_SERVER_PASSWORD"],
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--cd",
|
||||
"/home/dev/workspace",
|
||||
"--exec",
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
"--port",
|
||||
"0",
|
||||
])
|
||||
assert.equal(spec.cwd, undefined)
|
||||
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID:OPENCODE_SERVER_PASSWORD")
|
||||
})
|
||||
|
||||
it("upgrades existing WSLENV path entries to include /p", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
env: {
|
||||
OPENCODE_CONFIG_DIR: String.raw`C:\Users\dev\AppData\Roaming\CodeNomad\opencode-config`,
|
||||
WSLENV: "OPENCODE_CONFIG_DIR:CODENOMAD_INSTANCE_ID/u",
|
||||
},
|
||||
propagateEnvKeys: ["OPENCODE_CONFIG_DIR", "CODENOMAD_INSTANCE_ID"],
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.env?.WSLENV, "OPENCODE_CONFIG_DIR/p:CODENOMAD_INSTANCE_ID/u")
|
||||
})
|
||||
|
||||
it("propagates inherited known path variables even when they are not explicitly requested", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
env: {
|
||||
NODE_EXTRA_CA_CERTS: String.raw`C:\certs\root.pem`,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.env?.WSLENV, "NODE_EXTRA_CA_CERTS/p")
|
||||
})
|
||||
|
||||
it("uses wslpath for Windows workspace folders instead of assuming /mnt", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve", "--port", "0"],
|
||||
{
|
||||
cwd: String.raw`C:\Users\dev\workspace`,
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||
"codenomad-wsl-launch",
|
||||
String.raw`C:\Users\dev\workspace`,
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
"--port",
|
||||
"0",
|
||||
])
|
||||
})
|
||||
|
||||
it("uses wslpath for UNC network workspace folders", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
cwd: String.raw`\\server\share\workspace`,
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
'cd "$(wslpath -au "$1")" && shift && exec "$@"',
|
||||
"codenomad-wsl-launch",
|
||||
String.raw`\\server\share\workspace`,
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
])
|
||||
})
|
||||
|
||||
it("can wrap WSL launches to emit the Linux PID marker", () => {
|
||||
const spec = buildWindowsSpawnSpec(
|
||||
String.raw`\\wsl.localhost\Ubuntu\home\dev\.opencode\bin\opencode`,
|
||||
["serve"],
|
||||
{
|
||||
cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\workspace`,
|
||||
wslPidMarker: "__CODENOMAD_WSL_PID__:",
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, [
|
||||
"--distribution",
|
||||
"Ubuntu",
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
`printf '%s%s\\n' '__CODENOMAD_WSL_PID__:' "$$" && cd "$1" && shift && exec "$@"`,
|
||||
"codenomad-wsl-launch",
|
||||
"/home/dev/workspace",
|
||||
"/home/dev/.opencode/bin/opencode",
|
||||
"serve",
|
||||
])
|
||||
assert.equal(spec.wsl?.pidMarker, "__CODENOMAD_WSL_PID__:")
|
||||
})
|
||||
|
||||
it("builds the WSL kill command for tracked Linux PIDs", () => {
|
||||
const spec = buildWslSignalSpec("Ubuntu", 4321, "SIGTERM")
|
||||
|
||||
assert.equal(spec.command, "wsl.exe")
|
||||
assert.deepEqual(spec.args, ["--distribution", "Ubuntu", "--exec", "kill", "-TERM", "4321"])
|
||||
})
|
||||
})
|
||||
@@ -4,100 +4,10 @@ import path from "path"
|
||||
import { EventBus } from "../events/bus"
|
||||
import { LogLevel, WorkspaceLogEntry } from "../api-types"
|
||||
import { Logger } from "../logger"
|
||||
|
||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||
|
||||
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
|
||||
|
||||
export function buildSpawnSpec(binaryPath: string, args: string[]) {
|
||||
if (process.platform !== "win32") {
|
||||
return { command: binaryPath, args, options: {} as const }
|
||||
}
|
||||
|
||||
const extension = path.extname(binaryPath).toLowerCase()
|
||||
|
||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||
const comspec = process.env.ComSpec || "cmd.exe"
|
||||
// cmd.exe requires the full command as a single string.
|
||||
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||
|
||||
return {
|
||||
command: comspec,
|
||||
args: ["/d", "/s", "/c", commandLine],
|
||||
options: { windowsVerbatimArguments: true } as const,
|
||||
}
|
||||
}
|
||||
|
||||
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||
// powershell.exe ships with Windows. (pwsh may not.)
|
||||
return {
|
||||
command: "powershell.exe",
|
||||
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||
options: {} as const,
|
||||
}
|
||||
}
|
||||
|
||||
return { command: binaryPath, args, options: {} as const }
|
||||
}
|
||||
|
||||
export function probeBinaryVersion(binaryPath: string): {
|
||||
valid: boolean
|
||||
version?: string
|
||||
reported?: string
|
||||
error?: string
|
||||
} {
|
||||
if (!binaryPath) {
|
||||
return { valid: false, error: "Missing binary path" }
|
||||
}
|
||||
|
||||
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
||||
|
||||
try {
|
||||
const result = spawnSync(spec.command, spec.args, {
|
||||
encoding: "utf8",
|
||||
windowsVerbatimArguments: Boolean(
|
||||
(spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments,
|
||||
),
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { valid: false, error: result.error.message }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim()
|
||||
const stdout = result.stdout?.trim()
|
||||
const combined = stderr || stdout
|
||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||
return { valid: false, error }
|
||||
}
|
||||
|
||||
const stdoutLines = String(result.stdout ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
const stderrLines = String(result.stderr ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
// Prefer stdout; fall back to stderr (some tools report version there).
|
||||
const reported = stdoutLines[0] ?? stderrLines[0]
|
||||
if (!reported) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const versionMatch = reported.match(VERSION_REGEX)
|
||||
const version = versionMatch?.[1]
|
||||
return { valid: true, version, reported }
|
||||
} catch (error) {
|
||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
import { buildSpawnSpec, buildWslSignalSpec } from "./spawn"
|
||||
|
||||
const SENSITIVE_ENV_KEY = /(PASSWORD|TOKEN|SECRET)/i
|
||||
const WSL_PID_MARKER = "__CODENOMAD_WSL_PID__:"
|
||||
|
||||
function redactEnvironment(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
||||
const redacted: Record<string, string | undefined> = {}
|
||||
@@ -130,6 +40,10 @@ export interface ProcessExitInfo {
|
||||
interface ManagedProcess {
|
||||
child: ChildProcess
|
||||
requestedStop: boolean
|
||||
wsl?: {
|
||||
distro: string
|
||||
linuxPid: number | null
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceRuntime {
|
||||
@@ -167,7 +81,13 @@ export class WorkspaceRuntime {
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const spec = buildSpawnSpec(options.binaryPath, args)
|
||||
const propagatedEnvKeys = Object.keys(options.environment ?? {})
|
||||
const spec = buildSpawnSpec(options.binaryPath, args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
propagateEnvKeys: propagatedEnvKeys,
|
||||
wslPidMarker: WSL_PID_MARKER,
|
||||
})
|
||||
const commandLine = [spec.command, ...spec.args].join(" ")
|
||||
this.logger.info(
|
||||
{
|
||||
@@ -197,14 +117,18 @@ export class WorkspaceRuntime {
|
||||
)
|
||||
const detached = process.platform !== "win32"
|
||||
const child = spawn(spec.command, spec.args, {
|
||||
cwd: options.folder,
|
||||
env,
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached,
|
||||
...spec.options,
|
||||
})
|
||||
|
||||
const managed: ManagedProcess = { child, requestedStop: false }
|
||||
const managed: ManagedProcess = {
|
||||
child,
|
||||
requestedStop: false,
|
||||
...(spec.wsl ? { wsl: { distro: spec.wsl.distro, linuxPid: null } } : {}),
|
||||
}
|
||||
this.processes.set(options.workspaceId, managed)
|
||||
|
||||
let stdoutBuffer = ""
|
||||
@@ -284,6 +208,15 @@ export class WorkspaceRuntime {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
if (managed.wsl && trimmed.startsWith(WSL_PID_MARKER)) {
|
||||
const linuxPid = Number.parseInt(trimmed.slice(WSL_PID_MARKER.length), 10)
|
||||
if (Number.isFinite(linuxPid) && linuxPid > 0) {
|
||||
managed.wsl.linuxPid = linuxPid
|
||||
this.logger.debug({ workspaceId: options.workspaceId, linuxPid }, "Captured WSL OpenCode PID")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
recentStdout.push(trimmed)
|
||||
if (recentStdout.length > MAX_OUTPUT_LINES) {
|
||||
recentStdout.shift()
|
||||
@@ -398,11 +331,44 @@ export class WorkspaceRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
const trySignalWslProcess = (signal: NodeJS.Signals) => {
|
||||
if (process.platform !== "win32" || !managed.wsl?.linuxPid) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = buildWslSignalSpec(managed.wsl.distro, managed.wsl.linuxPid, signal)
|
||||
const result = spawnSync(spec.command, spec.args, { encoding: "utf8" })
|
||||
const exitCode = result.status
|
||||
if (exitCode === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const stderr = (result.stderr ?? "").toString().toLowerCase()
|
||||
const stdout = (result.stdout ?? "").toString().toLowerCase()
|
||||
const combined = `${stdout}\n${stderr}`
|
||||
if (combined.includes("no such process") || combined.includes("not found")) {
|
||||
return true
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
{ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, exitCode, stderr: result.stderr, stdout: result.stdout },
|
||||
"WSL kill failed",
|
||||
)
|
||||
return false
|
||||
} catch (error) {
|
||||
this.logger.debug({ workspaceId, pid, linuxPid: managed.wsl.linuxPid, distro: managed.wsl.distro, err: error }, "WSL kill failed to execute")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendStopSignal = (signal: NodeJS.Signals) => {
|
||||
if (process.platform === "win32") {
|
||||
// Best-effort: terminate the whole process tree rooted at pid.
|
||||
// Use /F only for escalation.
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
// WSL-backed launches need a Linux signal first because the tracked Windows PID belongs to wsl.exe.
|
||||
if (!trySignalWslProcess(signal)) {
|
||||
// Fallback to the Windows process tree rooted at pid. Use /F only for escalation.
|
||||
tryTaskkill(signal === "SIGKILL")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
307
packages/server/src/workspaces/spawn.ts
Normal file
307
packages/server/src/workspaces/spawn.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { spawnSync } from "child_process"
|
||||
import path from "path"
|
||||
|
||||
export const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"])
|
||||
export const WINDOWS_POWERSHELL_EXTENSIONS = new Set([".ps1"])
|
||||
|
||||
const VERSION_REGEX = /([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/
|
||||
const WSL_UNC_PATH_REGEX = /^\\\\wsl(?:\.localhost|\$)\\([^\\/]+)(?:[\\/](.*))?$/i
|
||||
const WSL_PATH_ENV_KEYS = new Set(["OPENCODE_CONFIG_DIR", "NODE_EXTRA_CA_CERTS"])
|
||||
|
||||
export interface SpawnSpec {
|
||||
command: string
|
||||
args: string[]
|
||||
options: {
|
||||
windowsVerbatimArguments?: boolean
|
||||
}
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
wsl?: {
|
||||
distro: string
|
||||
pidMarker?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface BuildSpawnSpecOptions {
|
||||
cwd?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
propagateEnvKeys?: string[]
|
||||
wslPidMarker?: string
|
||||
}
|
||||
|
||||
interface WslPath {
|
||||
distro: string
|
||||
linuxPath: string
|
||||
}
|
||||
|
||||
export type WslWorkingDirectory =
|
||||
| { kind: "linux"; path: string }
|
||||
| { kind: "windows"; path: string }
|
||||
|
||||
export function parseWslUncPath(input: string): WslPath | null {
|
||||
const normalized = input.trim().replace(/\//g, "\\")
|
||||
const match = normalized.match(WSL_UNC_PATH_REGEX)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const distro = match[1] ?? ""
|
||||
const remainder = match[2] ?? ""
|
||||
const segments = remainder.split(/\\+/).filter((segment) => segment.length > 0)
|
||||
|
||||
return {
|
||||
distro,
|
||||
linuxPath: segments.length > 0 ? `/${segments.join("/")}` : "/",
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveWslWorkingDirectory(folder: string, distro: string): WslWorkingDirectory | null {
|
||||
const wslFolder = parseWslUncPath(folder)
|
||||
if (wslFolder) {
|
||||
return wslFolder.distro.toLowerCase() === distro.toLowerCase() ? { kind: "linux", path: wslFolder.linuxPath } : null
|
||||
}
|
||||
|
||||
const windowsFolder = normalizeWindowsPath(folder)
|
||||
return windowsFolder ? { kind: "windows", path: windowsFolder } : null
|
||||
}
|
||||
|
||||
export function buildWindowsSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||
const wslPath = parseWslUncPath(binaryPath)
|
||||
if (wslPath) {
|
||||
return buildWslSpawnSpec(wslPath, args, options)
|
||||
}
|
||||
|
||||
const extension = path.extname(binaryPath).toLowerCase()
|
||||
|
||||
if (WINDOWS_CMD_EXTENSIONS.has(extension)) {
|
||||
const comspec = process.env.ComSpec || "cmd.exe"
|
||||
// cmd.exe requires the full command as a single string.
|
||||
// Using the ""<script> <args>"" pattern ensures paths with spaces are handled.
|
||||
const commandLine = `""${binaryPath}" ${args.join(" ")}"`
|
||||
|
||||
return {
|
||||
command: comspec,
|
||||
args: ["/d", "/s", "/c", commandLine],
|
||||
options: { windowsVerbatimArguments: true },
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
if (WINDOWS_POWERSHELL_EXTENSIONS.has(extension)) {
|
||||
// powershell.exe ships with Windows. (pwsh may not.)
|
||||
return {
|
||||
command: "powershell.exe",
|
||||
args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, ...args],
|
||||
options: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command: binaryPath,
|
||||
args,
|
||||
options: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSpawnSpec(binaryPath: string, args: string[], options: BuildSpawnSpecOptions = {}): SpawnSpec {
|
||||
if (process.platform !== "win32") {
|
||||
return {
|
||||
command: binaryPath,
|
||||
args,
|
||||
options: {},
|
||||
cwd: options.cwd,
|
||||
env: options.env,
|
||||
}
|
||||
}
|
||||
|
||||
return buildWindowsSpawnSpec(binaryPath, args, options)
|
||||
}
|
||||
|
||||
export function buildWslSignalSpec(distro: string, linuxPid: number, signal: NodeJS.Signals): SpawnSpec {
|
||||
return {
|
||||
command: "wsl.exe",
|
||||
args: ["--distribution", distro, "--exec", "kill", signal === "SIGKILL" ? "-KILL" : "-TERM", String(linuxPid)],
|
||||
options: {},
|
||||
wsl: { distro },
|
||||
}
|
||||
}
|
||||
|
||||
export function probeBinaryVersion(binaryPath: string): {
|
||||
valid: boolean
|
||||
version?: string
|
||||
reported?: string
|
||||
error?: string
|
||||
} {
|
||||
if (!binaryPath) {
|
||||
return { valid: false, error: "Missing binary path" }
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = buildSpawnSpec(binaryPath, ["--version"])
|
||||
const result = spawnSync(spec.command, spec.args, {
|
||||
encoding: "utf8",
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
windowsVerbatimArguments: Boolean(spec.options.windowsVerbatimArguments),
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { valid: false, error: result.error.message }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim()
|
||||
const stdout = result.stdout?.trim()
|
||||
const combined = stderr || stdout
|
||||
const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}`
|
||||
return { valid: false, error }
|
||||
}
|
||||
|
||||
const stdoutLines = String(result.stdout ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
const stderrLines = String(result.stderr ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
// Prefer stdout; fall back to stderr (some tools report version there).
|
||||
const reported = stdoutLines[0] ?? stderrLines[0]
|
||||
if (!reported) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const versionMatch = reported.match(VERSION_REGEX)
|
||||
const version = versionMatch?.[1]
|
||||
return { valid: true, version, reported }
|
||||
} catch (error) {
|
||||
return { valid: false, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
|
||||
function buildWslSpawnSpec(wslPath: WslPath, args: string[], options: BuildSpawnSpecOptions): SpawnSpec {
|
||||
const workingDirectory = options.cwd ? resolveWslWorkingDirectory(options.cwd, wslPath.distro) : undefined
|
||||
if (options.cwd && !workingDirectory) {
|
||||
throw new Error(
|
||||
`Unable to translate workspace folder for WSL binary in distro "${wslPath.distro}": ${options.cwd}`,
|
||||
)
|
||||
}
|
||||
|
||||
const wslArgs = ["--distribution", wslPath.distro]
|
||||
const shouldWrapWithShell = Boolean(options.wslPidMarker) || workingDirectory?.kind === "windows"
|
||||
|
||||
if (!shouldWrapWithShell && workingDirectory?.kind === "linux") {
|
||||
wslArgs.push("--cd", workingDirectory.path)
|
||||
}
|
||||
|
||||
if (shouldWrapWithShell) {
|
||||
const launchScript = buildWslLaunchScript(workingDirectory ?? undefined, options.wslPidMarker)
|
||||
wslArgs.push(
|
||||
"--exec",
|
||||
"sh",
|
||||
"-lc",
|
||||
launchScript,
|
||||
"codenomad-wsl-launch",
|
||||
)
|
||||
if (workingDirectory) {
|
||||
wslArgs.push(workingDirectory.path)
|
||||
}
|
||||
wslArgs.push(
|
||||
wslPath.linuxPath,
|
||||
...args,
|
||||
)
|
||||
} else {
|
||||
wslArgs.push("--exec", wslPath.linuxPath, ...args)
|
||||
}
|
||||
|
||||
return {
|
||||
command: "wsl.exe",
|
||||
args: wslArgs,
|
||||
options: {},
|
||||
env: buildWslEnvironment(options.env, options.propagateEnvKeys),
|
||||
wsl: { distro: wslPath.distro, pidMarker: options.wslPidMarker },
|
||||
}
|
||||
}
|
||||
|
||||
function buildWslLaunchScript(workingDirectory: WslWorkingDirectory | undefined, pidMarker: string | undefined): string {
|
||||
const steps: string[] = []
|
||||
|
||||
if (pidMarker) {
|
||||
steps.push(`printf '%s%s\\n' '${pidMarker}' "$$"`)
|
||||
}
|
||||
|
||||
if (workingDirectory?.kind === "linux") {
|
||||
steps.push('cd "$1"')
|
||||
steps.push("shift")
|
||||
} else if (workingDirectory?.kind === "windows") {
|
||||
steps.push('cd "$(wslpath -au "$1")"')
|
||||
steps.push("shift")
|
||||
}
|
||||
|
||||
steps.push('exec "$@"')
|
||||
return steps.join(" && ")
|
||||
}
|
||||
|
||||
function normalizeWindowsPath(input: string): string | null {
|
||||
const normalized = path.win32.normalize(input.trim().replace(/\//g, "\\"))
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (/^[A-Za-z]:/.test(normalized) || normalized.startsWith("\\\\")) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function buildWslEnvironment(env: NodeJS.ProcessEnv | undefined, propagateEnvKeys: string[] | undefined): NodeJS.ProcessEnv | undefined {
|
||||
if (!env) {
|
||||
return env
|
||||
}
|
||||
|
||||
const keysToPropagate = Array.from(
|
||||
new Set([
|
||||
...(propagateEnvKeys ?? []).filter((key) => env[key] !== undefined),
|
||||
...Array.from(WSL_PATH_ENV_KEYS).filter((key) => env[key] !== undefined),
|
||||
]),
|
||||
)
|
||||
if (keysToPropagate.length === 0) {
|
||||
return env
|
||||
}
|
||||
|
||||
const next = { ...env }
|
||||
const entries = (next.WSLENV ?? "").split(":").filter((entry) => entry.length > 0)
|
||||
const byName = new Map(entries.map((entry) => [entry.split("/")[0] ?? entry, entry]))
|
||||
|
||||
for (const key of keysToPropagate) {
|
||||
const existingEntry = byName.get(key)
|
||||
if (existingEntry) {
|
||||
byName.set(key, ensureWslenvEntry(existingEntry, WSL_PATH_ENV_KEYS.has(key)))
|
||||
continue
|
||||
}
|
||||
byName.set(key, WSL_PATH_ENV_KEYS.has(key) ? `${key}/p` : key)
|
||||
}
|
||||
|
||||
next.WSLENV = Array.from(byName.values()).join(":")
|
||||
return next
|
||||
}
|
||||
|
||||
function ensureWslenvEntry(entry: string, requiresPathTranslation: boolean): string {
|
||||
if (!requiresPathTranslation) {
|
||||
return entry
|
||||
}
|
||||
|
||||
const [name, rawFlags = ""] = entry.split("/")
|
||||
if (rawFlags.includes("p")) {
|
||||
return entry
|
||||
}
|
||||
|
||||
return rawFlags.length > 0 ? `${name}/${rawFlags}p` : `${name}/p`
|
||||
}
|
||||
@@ -20,7 +20,6 @@ use tauri::webview::Webview;
|
||||
use tauri::{
|
||||
AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder, WindowEvent, Wry,
|
||||
};
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||
use tauri_plugin_global_shortcut::{
|
||||
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
|
||||
};
|
||||
@@ -41,6 +40,8 @@ const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
|
||||
const ZOOM_STEP: f64 = 0.1;
|
||||
const MIN_ZOOM_LEVEL: f64 = 0.2;
|
||||
const MAX_ZOOM_LEVEL: f64 = 5.0;
|
||||
const LOCAL_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'local';";
|
||||
const REMOTE_WINDOW_CONTEXT_SCRIPT: &str = "window.__CODENOMAD_WINDOW_CONTEXT__ = 'remote';";
|
||||
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
|
||||
@@ -78,34 +79,6 @@ fn schedule_remote_proxy_session_cleanup(app: AppHandle, session_id: String) {
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -329,6 +302,7 @@ async fn open_remote_window_impl(
|
||||
let initial_url = window_url.clone();
|
||||
|
||||
let window = WebviewWindowBuilder::new(&app, label.clone(), WebviewUrl::External(initial_url.clone()))
|
||||
.initialization_script(REMOTE_WINDOW_CONTEXT_SCRIPT)
|
||||
.title(title)
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
@@ -367,6 +341,24 @@ async fn open_remote_window_impl(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn needs_local_certificate_install() -> Result<bool, String> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let local_cert = cert_manager::ensure_local_cert().map_err(|err| {
|
||||
format!("Failed to load the local HTTPS certificate for the remote proxy window: {err}")
|
||||
})?;
|
||||
return 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}")
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Result<(), String> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -379,17 +371,6 @@ async fn open_remote_window(app: AppHandle, payload: RemoteWindowPayload) -> Res
|
||||
"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) {
|
||||
return Err(format!(
|
||||
"Failed to trust the local CodeNomad CA certificate. Accept the certificate installation prompt and try again: {err}"
|
||||
@@ -564,6 +545,9 @@ fn main() {
|
||||
.setup(|app| {
|
||||
set_windows_app_user_model_id();
|
||||
build_menu(&app.handle())?;
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.eval(LOCAL_WINDOW_CONTEXT_SCRIPT);
|
||||
}
|
||||
if let Some(shortcut) = fullscreen_shortcut() {
|
||||
let shortcut_manager = app.handle().global_shortcut();
|
||||
let _ = shortcut_manager.register(shortcut.clone());
|
||||
@@ -598,6 +582,7 @@ fn main() {
|
||||
cli_restart,
|
||||
wake_lock_start,
|
||||
wake_lock_stop,
|
||||
needs_local_certificate_install,
|
||||
open_remote_window
|
||||
])
|
||||
.on_menu_event(|app_handle, event| {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { getLogger } from "./lib/logger"
|
||||
import { launchError, showLaunchError, clearLaunchError } from "./stores/launch-errors"
|
||||
import { formatLaunchErrorMessage, isMissingBinaryMessage } from "./lib/launch-errors"
|
||||
import { initReleaseNotifications } from "./stores/releases"
|
||||
import { runtimeEnv } from "./lib/runtime-env"
|
||||
import { isTauriHost, isWebHost, runtimeEnv } from "./lib/runtime-env"
|
||||
import { useI18n } from "./lib/i18n"
|
||||
import { setWakeLockDesired } from "./lib/native/wake-lock"
|
||||
import {
|
||||
@@ -137,7 +137,7 @@ const App: Component = () => {
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const shouldShow =
|
||||
runtimeEnv.host !== "web" && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||
!isWebHost() && runtimeEnv.platform !== "mobile" && (preferences().showKeyboardShortcutHints ?? true)
|
||||
document.documentElement.dataset.keyboardHints = shouldShow ? "show" : "hide"
|
||||
})
|
||||
|
||||
@@ -444,7 +444,7 @@ const App: Component = () => {
|
||||
|
||||
// Listen for Tauri menu events
|
||||
onMount(() => {
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
const tauriBridge = (window as { __TAURI__?: { event?: { listen: (event: string, handler: (event: { payload: unknown }) => void) => Promise<() => void> } } }).__TAURI__
|
||||
if (tauriBridge?.event) {
|
||||
let unlistenMenu: (() => void) | null = null
|
||||
|
||||
@@ -58,6 +58,16 @@ function resolveAbsolutePath(root: string, relativePath: string) {
|
||||
return `${trimmedRoot}${normalized}`
|
||||
}
|
||||
|
||||
function getAbsolutePathFromMetadata(metadata: FileSystemListingMetadata | null) {
|
||||
if (!metadata || metadata.pathKind === "drives") {
|
||||
return ""
|
||||
}
|
||||
if (metadata.pathKind === "relative") {
|
||||
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
||||
}
|
||||
return metadata.displayPath
|
||||
}
|
||||
|
||||
type FolderRow =
|
||||
| { type: "up"; path: string }
|
||||
| { type: "folder"; entry: FileSystemEntry }
|
||||
@@ -67,6 +77,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
const [rootPath, setRootPath] = createSignal("")
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
const [error, setError] = createSignal<string | null>(null)
|
||||
const [pathInput, setPathInput] = createSignal("")
|
||||
const [pathInputDirty, setPathInputDirty] = createSignal(false)
|
||||
const [creatingFolder, setCreatingFolder] = createSignal(false)
|
||||
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
|
||||
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
|
||||
@@ -75,12 +87,16 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
|
||||
const metadataCache = new Map<string, FileSystemListingMetadata>()
|
||||
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
|
||||
let latestNavigationId = 0
|
||||
|
||||
function resetState() {
|
||||
setRootPath("")
|
||||
setDirectoryChildren(new Map<string, FileSystemEntry[]>())
|
||||
setLoadingPaths(new Set<string>())
|
||||
setCurrentPathKey(null)
|
||||
setCurrentMetadata(null)
|
||||
setPathInput("")
|
||||
setPathInputDirty(false)
|
||||
metadataCache.clear()
|
||||
inFlightRequests.clear()
|
||||
setError(null)
|
||||
@@ -109,11 +125,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
async function initialize() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const metadata = await loadDirectory()
|
||||
applyMetadata(metadata)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||
setError(message)
|
||||
await navigateTo()
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -197,13 +209,22 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
}
|
||||
|
||||
async function navigateTo(path?: string) {
|
||||
const navigationId = ++latestNavigationId
|
||||
setError(null)
|
||||
try {
|
||||
const metadata = await loadDirectory(path)
|
||||
if (navigationId !== latestNavigationId) {
|
||||
return null
|
||||
}
|
||||
applyMetadata(metadata)
|
||||
return metadata
|
||||
} catch (err) {
|
||||
if (navigationId !== latestNavigationId) {
|
||||
return null
|
||||
}
|
||||
const message = err instanceof Error ? err.message : t("directoryBrowser.load.errorFallback")
|
||||
setError(message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,31 +246,58 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
})
|
||||
|
||||
function handleNavigateTo(path: string) {
|
||||
setPathInputDirty(false)
|
||||
void navigateTo(path)
|
||||
}
|
||||
|
||||
function handleNavigateUp() {
|
||||
const parent = currentMetadata()?.parentPath
|
||||
if (parent) {
|
||||
setPathInputDirty(false)
|
||||
void navigateTo(parent)
|
||||
}
|
||||
}
|
||||
|
||||
const currentAbsolutePath = createMemo(() => {
|
||||
const metadata = currentMetadata()
|
||||
if (!metadata) {
|
||||
return ""
|
||||
return getAbsolutePathFromMetadata(currentMetadata())
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const absolutePath = currentAbsolutePath()
|
||||
if (!pathInputDirty()) {
|
||||
setPathInput(absolutePath)
|
||||
}
|
||||
if (metadata.pathKind === "drives") {
|
||||
return ""
|
||||
}
|
||||
if (metadata.pathKind === "relative") {
|
||||
return resolveAbsolutePath(metadata.rootPath, metadata.currentPath)
|
||||
}
|
||||
return metadata.displayPath
|
||||
})
|
||||
|
||||
const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath()))
|
||||
const canSubmitPath = createMemo(() => pathInput().trim().length > 0)
|
||||
|
||||
async function handlePathSubmit() {
|
||||
const target = pathInput().trim()
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
const metadata = await navigateTo(target)
|
||||
if (!metadata) {
|
||||
return
|
||||
}
|
||||
setPathInputDirty(false)
|
||||
setPathInput(getAbsolutePathFromMetadata(metadata))
|
||||
}
|
||||
|
||||
async function handleSelectCurrent() {
|
||||
const target = pathInput().trim()
|
||||
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
|
||||
if (!metadata) {
|
||||
return
|
||||
}
|
||||
setPathInputDirty(false)
|
||||
const absolute = getAbsolutePathFromMetadata(metadata)
|
||||
if (absolute) {
|
||||
setPathInput(absolute)
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}
|
||||
|
||||
function handleEntrySelect(entry: FileSystemEntry) {
|
||||
const absolutePath = entry.absolutePath
|
||||
@@ -262,10 +310,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
|
||||
async function handleCreateFolder() {
|
||||
if (creatingFolder()) return
|
||||
const metadata = currentMetadata()
|
||||
const target = pathInput().trim()
|
||||
const metadata = target && target !== currentAbsolutePath() ? await navigateTo(target) : currentMetadata()
|
||||
if (!metadata || metadata.pathKind === "drives") {
|
||||
return
|
||||
}
|
||||
setPathInputDirty(false)
|
||||
setPathInput(getAbsolutePathFromMetadata(metadata))
|
||||
|
||||
const name =
|
||||
(await showPromptDialog(t("directoryBrowser.createFolder.promptMessage"), {
|
||||
@@ -338,19 +389,29 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
|
||||
<div class="directory-browser-current">
|
||||
<div class="directory-browser-current-meta">
|
||||
<span class="directory-browser-current-label">{t("directoryBrowser.currentFolder")}</span>
|
||||
<span class="directory-browser-current-path">{currentAbsolutePath()}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={pathInput()}
|
||||
onInput={(event) => {
|
||||
setPathInput(event.currentTarget.value)
|
||||
setPathInputDirty(true)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void handlePathSubmit()
|
||||
}
|
||||
}}
|
||||
spellcheck={false}
|
||||
class="selector-input directory-browser-current-path"
|
||||
/>
|
||||
</div>
|
||||
<div class="directory-browser-current-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
|
||||
disabled={!canSelectCurrent() || creatingFolder()}
|
||||
onClick={() => {
|
||||
const absolute = currentAbsolutePath()
|
||||
if (absolute) {
|
||||
props.onSelect(absolute)
|
||||
}
|
||||
}}
|
||||
disabled={(!canSelectCurrent() && !canSubmitPath()) || creatingFolder()}
|
||||
onClick={() => void handleSelectCurrent()}
|
||||
>
|
||||
{t("directoryBrowser.selectCurrent")}
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight, MonitorUp, S
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import DirectoryBrowserDialog from "./directory-browser-dialog"
|
||||
import Kbd from "./kbd"
|
||||
import { openNativeFolderDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { openNativeFolderDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
|
||||
import { useFolderDrop } from "../lib/hooks/use-folder-drop"
|
||||
import VersionPill from "./version-pill"
|
||||
import { DiscordSymbolIcon, GitHubMarkIcon } from "./brand-icons"
|
||||
@@ -16,7 +16,7 @@ import { showAlertDialog } from "../stores/alerts"
|
||||
import { openSettings, settingsOpen } from "../stores/settings-screen"
|
||||
import { openExternalUrl } from "../lib/external-url"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import { runtimeEnv } from "../lib/runtime-env"
|
||||
import { canOpenRemoteWindows, isTauriHost } from "../lib/runtime-env"
|
||||
import { openRemoteServerWindow } from "../lib/native/remote-window"
|
||||
|
||||
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href
|
||||
@@ -58,7 +58,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const [serverDialogError, setServerDialogError] = createSignal<string | null>(null)
|
||||
const [isSavingServer, setIsSavingServer] = createSignal(false)
|
||||
const [connectingServerId, setConnectingServerId] = createSignal<string | null>(null)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
let recentListRef: HTMLDivElement | undefined
|
||||
|
||||
type LanguageOption = { value: Locale; label: string }
|
||||
@@ -78,6 +77,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
const folders = () => recentFolders()
|
||||
const serverList = () => remoteServers()
|
||||
const isLoading = () => Boolean(props.isLoading)
|
||||
const canUseRemoteServerWindows = () => canOpenRemoteWindows()
|
||||
|
||||
function getActiveListLength() {
|
||||
return activeTab() === "local" ? folders().length : serverList().length
|
||||
@@ -124,17 +124,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
const normalizedKey = e.key.toLowerCase()
|
||||
const isBrowseShortcut = (e.metaKey || e.ctrlKey) && !e.shiftKey && normalizedKey === "n"
|
||||
const blockedKeys = [
|
||||
"ArrowDown",
|
||||
"ArrowUp",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
"Home",
|
||||
"End",
|
||||
"Enter",
|
||||
"Backspace",
|
||||
"Delete",
|
||||
]
|
||||
const blockedKeys = ["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", "Enter"]
|
||||
|
||||
if (isLoading()) {
|
||||
if (isBrowseShortcut || blockedKeys.includes(e.key)) {
|
||||
@@ -192,21 +182,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleEnterKey()
|
||||
} else if (e.key === "Backspace" || e.key === "Delete") {
|
||||
e.preventDefault()
|
||||
if (listLength > 0 && focusMode() === "recent") {
|
||||
if (activeTab() === "local") {
|
||||
const folder = folders()[selectedIndex()]
|
||||
if (folder) {
|
||||
handleRemove(folder.path)
|
||||
}
|
||||
} else {
|
||||
const server = serverList()[selectedIndex()]
|
||||
if (server) {
|
||||
removeRemoteServerProfile(server.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +206,10 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
createEffect(() => {
|
||||
activeTab()
|
||||
if (!canUseRemoteServerWindows() && activeTab() !== "local") {
|
||||
setActiveTab("local")
|
||||
return
|
||||
}
|
||||
setSelectedIndex(0)
|
||||
setFocusMode("recent")
|
||||
})
|
||||
@@ -305,11 +284,16 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
}
|
||||
|
||||
function openServerDialog() {
|
||||
if (!canUseRemoteServerWindows()) return
|
||||
resetServerDialog()
|
||||
setIsServerDialogOpen(true)
|
||||
}
|
||||
|
||||
async function probeAndOpenServer(input: { id?: string; name: string; baseUrl: string; skipTlsVerify: boolean }, openWindow: boolean) {
|
||||
if (openWindow && !canUseRemoteServerWindows()) {
|
||||
throw new Error("Remote server windows can only be opened from a local desktop window")
|
||||
}
|
||||
|
||||
const trimmedName = input.name.trim()
|
||||
const trimmedUrl = input.baseUrl.trim()
|
||||
if (!trimmedName || !trimmedUrl) {
|
||||
@@ -334,7 +318,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
|
||||
if (openWindow) {
|
||||
const remoteProxySession =
|
||||
runtimeEnv.host === "tauri" && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
|
||||
isTauriHost() && profile.skipTlsVerify && profile.baseUrl.startsWith("https://")
|
||||
? await serverApi.createRemoteProxySession({
|
||||
baseUrl: profile.baseUrl,
|
||||
skipTlsVerify: profile.skipTlsVerify,
|
||||
@@ -379,6 +363,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
}
|
||||
|
||||
async function handleConnectSavedServer(id: string) {
|
||||
if (!canUseRemoteServerWindows()) return
|
||||
const target = remoteServers().find((entry) => entry.id === id)
|
||||
if (!target || connectingServerId()) return
|
||||
setConnectingServerId(id)
|
||||
@@ -397,7 +382,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
async function handleBrowse() {
|
||||
if (isLoading()) return
|
||||
setFocusMode("new")
|
||||
if (nativeDialogsAvailable) {
|
||||
if (supportsNativeDialogsInCurrentWindow()) {
|
||||
const fallbackPath = folders()[0]?.path
|
||||
const selected = await openNativeFolderDialog({
|
||||
title: t("folderSelection.dialog.title"),
|
||||
@@ -554,15 +539,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
>
|
||||
<Settings class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => openSettings("remote")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
<Show when={canUseRemoteServerWindows()}>
|
||||
<button
|
||||
type="button"
|
||||
class="selector-button selector-button-secondary w-auto p-2 inline-flex items-center justify-center"
|
||||
onClick={() => openSettings("remote")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={props.onClose}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -636,7 +623,7 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<div class="order-1 lg:order-2 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden">
|
||||
<div class="panel flex flex-col flex-1 min-h-0">
|
||||
<div class="panel-header !gap-0 !p-0">
|
||||
<div class="grid grid-cols-2 gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none">
|
||||
<div class={`grid ${canUseRemoteServerWindows() ? "grid-cols-2" : "grid-cols-1"} gap-0 overflow-hidden border border-base rounded-t-lg rounded-b-none`}>
|
||||
<button
|
||||
type="button"
|
||||
class="border-r border-base px-4 py-3 text-left transition-colors"
|
||||
@@ -671,35 +658,37 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-3 text-left transition-colors"
|
||||
classList={{
|
||||
"text-primary": activeTab() === "servers",
|
||||
"text-muted hover:text-secondary": activeTab() !== "servers",
|
||||
}}
|
||||
style={{
|
||||
"background-color": "var(--surface-secondary)",
|
||||
}}
|
||||
onClick={() => setActiveTab("servers")}
|
||||
>
|
||||
<div
|
||||
class="panel-title text-base"
|
||||
style={{
|
||||
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
<Show when={canUseRemoteServerWindows()}>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-3 text-left transition-colors"
|
||||
classList={{
|
||||
"text-primary": activeTab() === "servers",
|
||||
"text-muted hover:text-secondary": activeTab() !== "servers",
|
||||
}}
|
||||
>
|
||||
{t("folderSelection.tabs.servers")}
|
||||
</div>
|
||||
<p
|
||||
class="panel-subtitle mt-1"
|
||||
style={{
|
||||
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||
"background-color": "var(--surface-secondary)",
|
||||
}}
|
||||
onClick={() => setActiveTab("servers")}
|
||||
>
|
||||
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
||||
</p>
|
||||
</button>
|
||||
<div
|
||||
class="panel-title text-base"
|
||||
style={{
|
||||
color: activeTab() === "servers" ? "var(--text-primary)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("folderSelection.tabs.servers")}
|
||||
</div>
|
||||
<p
|
||||
class="panel-subtitle mt-1"
|
||||
style={{
|
||||
color: activeTab() === "servers" ? "var(--text-muted)" : "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{t("folderSelection.servers.count", { count: remoteServers().length })}
|
||||
</p>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -707,23 +696,25 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
when={activeTab() === "local"}
|
||||
fallback={
|
||||
<Show
|
||||
when={remoteServers().length > 0}
|
||||
when={canUseRemoteServerWindows() && remoteServers().length > 0}
|
||||
fallback={
|
||||
<div class="panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Globe class="w-12 h-12 mx-auto" />
|
||||
<Show when={canUseRemoteServerWindows()}>
|
||||
<div class="panel-empty-state flex-1">
|
||||
<div class="panel-empty-state-icon">
|
||||
<Globe class="w-12 h-12 mx-auto" />
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||
onClick={openServerDialog}
|
||||
>
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="panel-empty-state-title">{t("folderSelection.servers.empty.title")}</p>
|
||||
<p class="panel-empty-state-description">{t("folderSelection.servers.empty.description")}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="button-primary mt-4 w-auto self-center inline-flex items-center justify-center gap-2 px-4"
|
||||
onClick={openServerDialog}
|
||||
>
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div
|
||||
@@ -891,15 +882,17 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={openServerDialog}
|
||||
class="button-primary w-full flex items-center justify-center text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</div>
|
||||
</button>
|
||||
<Show when={canUseRemoteServerWindows()}>
|
||||
<button
|
||||
onClick={openServerDialog}
|
||||
class="button-primary w-full flex items-center justify-center text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Globe class="w-4 h-4" />
|
||||
<span>{t("folderSelection.actions.connectButton")}</span>
|
||||
</div>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* OpenCode settings section */}
|
||||
@@ -935,10 +928,6 @@ const FolderSelectionView: Component<FolderSelectionViewProps> = (props) => {
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>{t("folderSelection.hints.select")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>{t("folderSelection.hints.remove")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Kbd shortcut="cmd+n" class="kbd-hint" />
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Plus, MonitorUp, Bell, BellOff, Settings } from "lucide-solid"
|
||||
import { keyboardRegistry } from "../lib/keyboard-registry"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { isOsNotificationSupportedSync } from "../lib/os-notifications"
|
||||
import { canOpenRemoteWindows } from "../lib/runtime-env"
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { openSettings } from "../stores/settings-screen"
|
||||
import type { AppTabRecord } from "../stores/app-tabs"
|
||||
@@ -99,14 +100,16 @@ const InstanceTabs: Component<InstanceTabsProps> = (props) => {
|
||||
<Dynamic component={notificationIcon()} class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => openSettings("remote")}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
<Show when={canOpenRemoteWindows()}>
|
||||
<button
|
||||
class="new-tab-button tab-remote-button"
|
||||
onClick={() => openSettings("remote")}
|
||||
title={t("instanceTabs.remote.title")}
|
||||
aria-label={t("instanceTabs.remote.ariaLabel")}
|
||||
>
|
||||
<MonitorUp class="w-4 h-4" />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,9 +171,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
void handleEnterKey()
|
||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||
e.preventDefault()
|
||||
void handleDeleteKey()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,29 +184,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteKey() {
|
||||
const sessions = parentSessions()
|
||||
const index = selectedIndex()
|
||||
|
||||
if (index >= sessions.length) {
|
||||
return
|
||||
}
|
||||
|
||||
await handleSessionDelete(sessions[index].id)
|
||||
|
||||
const updatedSessions = parentSessions()
|
||||
if (updatedSessions.length === 0) {
|
||||
setFocusMode("new-session")
|
||||
setSelectedIndex(0)
|
||||
return
|
||||
}
|
||||
|
||||
const nextIndex = Math.min(index, updatedSessions.length - 1)
|
||||
setSelectedIndex(nextIndex)
|
||||
setFocusMode("sessions")
|
||||
scrollToIndex(nextIndex)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
|
||||
@@ -562,10 +536,6 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
|
||||
<kbd class="kbd">Enter</kbd>
|
||||
<span>{t("instanceWelcome.hints.resume")}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<kbd class="kbd">Del</kbd>
|
||||
<span>{t("instanceWelcome.hints.delete")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FolderOpen, Trash2, Check, AlertCircle, Loader2, Plus } from "lucide-so
|
||||
import { useConfig } from "../stores/preferences"
|
||||
import { serverApi } from "../lib/api-client"
|
||||
import FileSystemBrowserDialog from "./filesystem-browser-dialog"
|
||||
import { openNativeFileDialog, supportsNativeDialogs } from "../lib/native/native-functions"
|
||||
import { openNativeFileDialog, supportsNativeDialogsInCurrentWindow } from "../lib/native/native-functions"
|
||||
import { useI18n } from "../lib/i18n"
|
||||
import { getLogger } from "../lib/logger"
|
||||
const log = getLogger("actions")
|
||||
@@ -38,7 +38,6 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
const [versionInfo, setVersionInfo] = createSignal<Map<string, string>>(new Map<string, string>())
|
||||
const [validatingPaths, setValidatingPaths] = createSignal<Set<string>>(new Set<string>())
|
||||
const [isBinaryBrowserOpen, setIsBinaryBrowserOpen] = createSignal(false)
|
||||
const nativeDialogsAvailable = supportsNativeDialogs()
|
||||
|
||||
const binaries = () => opencodeBinaries()
|
||||
|
||||
@@ -139,7 +138,7 @@ const OpenCodeBinarySelector: Component<OpenCodeBinarySelectorProps> = (props) =
|
||||
async function handleBrowseBinary() {
|
||||
if (props.disabled) return
|
||||
setValidationError(null)
|
||||
if (nativeDialogsAvailable) {
|
||||
if (supportsNativeDialogsInCurrentWindow()) {
|
||||
const selected = await openNativeFileDialog({
|
||||
title: t("opencodeBinarySelector.dialog.title"),
|
||||
})
|
||||
|
||||
@@ -15,25 +15,33 @@ import { OpenCodeSettingsSection } from "./settings/opencode-settings-section"
|
||||
import { RemoteAccessSettingsSection } from "./settings/remote-access-settings-section"
|
||||
import { SpeechSettingsSection } from "./settings/speech-settings-section"
|
||||
import { SideCarsSettingsSection } from "./settings/sidecars-settings-section"
|
||||
import { canOpenRemoteWindows } from "../lib/runtime-env"
|
||||
|
||||
export const SettingsScreen: Component = () => {
|
||||
const { t } = useI18n()
|
||||
|
||||
const sections = createMemo(() => [
|
||||
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||
{ id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") },
|
||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||
])
|
||||
const sections = createMemo(() => {
|
||||
const items = [
|
||||
{ id: "appearance" as SettingsSectionId, icon: Paintbrush, label: t("settings.nav.appearance") },
|
||||
{ id: "notifications" as SettingsSectionId, icon: Bell, label: t("settings.nav.notifications") },
|
||||
{ id: "speech" as SettingsSectionId, icon: Volume2, label: t("settings.nav.speech") },
|
||||
{ id: "sidecars" as SettingsSectionId, icon: Globe, label: t("settings.nav.sidecars") },
|
||||
{ id: "opencode" as SettingsSectionId, icon: Terminal, label: t("settings.nav.opencode") },
|
||||
]
|
||||
|
||||
if (canOpenRemoteWindows()) {
|
||||
items.splice(2, 0, { id: "remote" as SettingsSectionId, icon: MonitorUp, label: t("settings.nav.remote") })
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const renderSection = () => {
|
||||
switch (activeSettingsSection()) {
|
||||
case "notifications":
|
||||
return <NotificationsSettingsSection />
|
||||
case "remote":
|
||||
return <RemoteAccessSettingsSection />
|
||||
return canOpenRemoteWindows() ? <RemoteAccessSettingsSection /> : <AppearanceSettingsSection />
|
||||
case "speech":
|
||||
return <SpeechSettingsSection />
|
||||
case "sidecars":
|
||||
|
||||
@@ -17,6 +17,8 @@ interface LspDiagnostic {
|
||||
range?: LspRange
|
||||
}
|
||||
|
||||
export type DiagnosticsMap = Record<string, LspDiagnostic[] | undefined>
|
||||
|
||||
export interface DiagnosticEntry {
|
||||
id: string
|
||||
severity: number
|
||||
@@ -30,7 +32,7 @@ export interface DiagnosticEntry {
|
||||
column: number
|
||||
}
|
||||
|
||||
function normalizeDiagnosticPath(path: string) {
|
||||
export function normalizeDiagnosticPath(path: string) {
|
||||
return path.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
@@ -53,49 +55,71 @@ export function extractDiagnostics(state: ToolState | undefined): DiagnosticEntr
|
||||
|
||||
const metadata = (state.metadata || {}) as Record<string, unknown>
|
||||
const input = (state.input || {}) as Record<string, unknown>
|
||||
const diagnosticsMap = metadata?.diagnostics as Record<string, LspDiagnostic[] | undefined> | undefined
|
||||
const diagnosticsMap = metadata?.diagnostics as DiagnosticsMap | undefined
|
||||
if (!diagnosticsMap) return []
|
||||
|
||||
const preferredPath = [input.filePath, metadata.filePath, metadata.filepath, input.path].find(
|
||||
(value) => typeof value === "string" && value.length > 0,
|
||||
) as string | undefined
|
||||
return buildDiagnosticEntries(diagnosticsMap, [input.filePath, metadata.filePath, metadata.filepath, input.path])
|
||||
}
|
||||
|
||||
const normalizedPreferred = preferredPath ? normalizeDiagnosticPath(preferredPath) : undefined
|
||||
if (!normalizedPreferred) return []
|
||||
const candidateEntries = Object.entries(diagnosticsMap).filter(([, items]) => Array.isArray(items) && items.length > 0)
|
||||
if (candidateEntries.length === 0) return []
|
||||
export function resolveDiagnosticsKey(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): string | undefined {
|
||||
if (Object.keys(diagnostics).length === 0) return undefined
|
||||
|
||||
const prioritizedEntries = candidateEntries.filter(([path]) => {
|
||||
const normalized = normalizeDiagnosticPath(path)
|
||||
return normalized === normalizedPreferred
|
||||
})
|
||||
const normalizedPreferred = preferredPaths
|
||||
.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
.map((value) => normalizeDiagnosticPath(value))
|
||||
|
||||
if (prioritizedEntries.length === 0) return []
|
||||
if (normalizedPreferred.length === 0) return undefined
|
||||
|
||||
for (const preferred of normalizedPreferred) {
|
||||
if (diagnostics[preferred]) return preferred
|
||||
}
|
||||
|
||||
const keys = Object.keys(diagnostics)
|
||||
|
||||
for (const preferred of normalizedPreferred) {
|
||||
const direct = keys.find((key) => normalizeDiagnosticPath(key) === preferred)
|
||||
if (direct) return direct
|
||||
}
|
||||
|
||||
for (const preferred of normalizedPreferred) {
|
||||
const suffixMatch = keys.find((key) => {
|
||||
const normalized = normalizeDiagnosticPath(key)
|
||||
return normalized === preferred || normalized.endsWith("/" + preferred)
|
||||
})
|
||||
if (suffixMatch) return suffixMatch
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function buildDiagnosticEntries(diagnostics: DiagnosticsMap, preferredPaths: Array<string | undefined>): DiagnosticEntry[] {
|
||||
const key = resolveDiagnosticsKey(diagnostics, preferredPaths)
|
||||
if (!key) return []
|
||||
|
||||
const list = diagnostics[key]
|
||||
if (!Array.isArray(list) || list.length === 0) return []
|
||||
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (const [pathKey, list] of prioritizedEntries) {
|
||||
if (!Array.isArray(list)) continue
|
||||
const normalizedPath = normalizeDiagnosticPath(pathKey)
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
entries.push({
|
||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedPath,
|
||||
displayPath: getRelativePath(normalizedPath),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
const normalizedPath = normalizeDiagnosticPath(key)
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
entries.push({
|
||||
id: `${normalizedPath}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedPath,
|
||||
displayPath: getRelativePath(normalizedPath),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
|
||||
@@ -1,107 +1,14 @@
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import type { ToolRenderer } from "../types"
|
||||
import { getRelativePath, getToolName, isToolStateCompleted, readToolStatePayload } from "../utils"
|
||||
import type { DiagnosticEntry } from "../diagnostics"
|
||||
|
||||
type LspRangePosition = {
|
||||
line?: number
|
||||
character?: number
|
||||
}
|
||||
|
||||
type LspRange = {
|
||||
start?: LspRangePosition
|
||||
}
|
||||
|
||||
type LspDiagnostic = {
|
||||
message?: string
|
||||
severity?: number
|
||||
range?: LspRange
|
||||
}
|
||||
import { buildDiagnosticEntries, type DiagnosticEntry, type DiagnosticsMap } from "../diagnostics"
|
||||
|
||||
type ApplyPatchFile = {
|
||||
filePath?: string
|
||||
relativePath?: string
|
||||
type?: string
|
||||
diff?: string
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function determineSeverityTone(severity?: number): DiagnosticEntry["tone"] {
|
||||
if (severity === 1) return "error"
|
||||
if (severity === 2) return "warning"
|
||||
return "info"
|
||||
}
|
||||
|
||||
function getSeverityMeta(tone: DiagnosticEntry["tone"], t: (key: string, params?: Record<string, unknown>) => string) {
|
||||
if (tone === "error") return { label: t("toolCall.diagnostics.severity.error.short"), icon: "!", rank: 0 }
|
||||
if (tone === "warning") return { label: t("toolCall.diagnostics.severity.warning.short"), icon: "!", rank: 1 }
|
||||
return { label: t("toolCall.diagnostics.severity.info.short"), icon: "i", rank: 2 }
|
||||
}
|
||||
|
||||
function resolveDiagnosticsKey(
|
||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
||||
file: ApplyPatchFile,
|
||||
): string | undefined {
|
||||
const absolute = typeof file.filePath === "string" ? normalizePath(file.filePath) : ""
|
||||
const relative = typeof file.relativePath === "string" ? normalizePath(file.relativePath) : ""
|
||||
if (absolute && diagnostics[absolute]) return absolute
|
||||
if (relative && diagnostics[relative]) return relative
|
||||
|
||||
if (absolute) {
|
||||
const direct = Object.keys(diagnostics).find((key) => normalizePath(key) === absolute)
|
||||
if (direct) return direct
|
||||
}
|
||||
|
||||
if (relative) {
|
||||
const suffixMatch = Object.keys(diagnostics).find((key) => {
|
||||
const normalized = normalizePath(key)
|
||||
return normalized === relative || normalized.endsWith("/" + relative)
|
||||
})
|
||||
if (suffixMatch) return suffixMatch
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function buildDiagnostics(
|
||||
diagnostics: Record<string, LspDiagnostic[] | undefined>,
|
||||
file: ApplyPatchFile,
|
||||
t: (key: string, params?: Record<string, unknown>) => string,
|
||||
): DiagnosticEntry[] {
|
||||
const key = resolveDiagnosticsKey(diagnostics, file)
|
||||
if (!key) return []
|
||||
const list = diagnostics[key]
|
||||
if (!Array.isArray(list) || list.length === 0) return []
|
||||
|
||||
const normalizedKey = normalizePath(key)
|
||||
const entries: DiagnosticEntry[] = []
|
||||
for (let index = 0; index < list.length; index++) {
|
||||
const diagnostic = list[index]
|
||||
if (!diagnostic || typeof diagnostic.message !== "string") continue
|
||||
|
||||
const tone = determineSeverityTone(typeof diagnostic.severity === "number" ? diagnostic.severity : undefined)
|
||||
const severityMeta = getSeverityMeta(tone, t)
|
||||
const line = typeof diagnostic.range?.start?.line === "number" ? diagnostic.range.start.line + 1 : 0
|
||||
const column = typeof diagnostic.range?.start?.character === "number" ? diagnostic.range.start.character + 1 : 0
|
||||
|
||||
entries.push({
|
||||
id: `${normalizedKey}-${index}-${diagnostic.message}`,
|
||||
severity: severityMeta.rank,
|
||||
tone,
|
||||
label: severityMeta.label,
|
||||
icon: severityMeta.icon,
|
||||
message: diagnostic.message,
|
||||
filePath: normalizedKey,
|
||||
displayPath: getRelativePath(normalizedKey),
|
||||
line,
|
||||
column,
|
||||
})
|
||||
}
|
||||
|
||||
return entries.sort((a, b) => a.severity - b.severity)
|
||||
patch?: string
|
||||
}
|
||||
|
||||
function DiagnosticsInline(props: { entries: DiagnosticEntry[]; label: string; t: (key: string, params?: Record<string, unknown>) => string }) {
|
||||
@@ -164,7 +71,7 @@ export const applyPatchRenderer: ToolRenderer = {
|
||||
})
|
||||
const diagnosticsMap = createMemo(() => {
|
||||
const value = (payload.metadata as any).diagnostics
|
||||
return value && typeof value === "object" ? (value as Record<string, LspDiagnostic[] | undefined>) : {}
|
||||
return value && typeof value === "object" ? (value as DiagnosticsMap) : {}
|
||||
})
|
||||
|
||||
if (files().length === 0) {
|
||||
@@ -178,9 +85,9 @@ export const applyPatchRenderer: ToolRenderer = {
|
||||
<For each={files()}>
|
||||
{(file, index) => {
|
||||
const labelBase = file.relativePath || file.filePath || t("toolCall.applyPatch.fileFallback", { number: index() + 1 })
|
||||
const diffText = typeof file.diff === "string" ? file.diff : ""
|
||||
const diffText = typeof file.diff === "string" ? file.diff : typeof file.patch === "string" ? file.patch : ""
|
||||
const filePath = typeof file.filePath === "string" ? file.filePath : file.relativePath
|
||||
const entries = createMemo(() => buildDiagnostics(diagnosticsMap(), file, t))
|
||||
const entries = createMemo(() => buildDiagnosticEntries(diagnosticsMap(), [file.filePath, file.relativePath]))
|
||||
|
||||
return (
|
||||
<div class="tool-call-apply-patch-file">
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
normalizeDroppedDirectoryPaths,
|
||||
supportsDesktopFolderDrop,
|
||||
} from "../native/desktop-file-drop"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { isTauriHost } from "../runtime-env"
|
||||
|
||||
interface UseFolderDropOptions {
|
||||
enabled: Accessor<boolean>
|
||||
@@ -94,7 +94,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
||||
|
||||
const bind: FolderDropBindings = {
|
||||
onDragEnter(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
@@ -102,7 +102,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
||||
setIsActive(true)
|
||||
},
|
||||
onDragOver(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !options.enabled() || !containsFileDrop(event)) {
|
||||
if (!isSupported || isTauriHost() || !options.enabled() || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
@@ -112,7 +112,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
||||
setIsActive(true)
|
||||
},
|
||||
onDragLeave(event) {
|
||||
if (!isSupported || runtimeEnv.host === "tauri" || !containsFileDrop(event)) {
|
||||
if (!isSupported || isTauriHost() || !containsFileDrop(event)) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
@@ -134,7 +134,7 @@ export function useFolderDrop(options: UseFolderDropOptions): {
|
||||
return
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Connecting...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Server name and URL are required.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Could not connect to the remote server.",
|
||||
"folderSelection.servers.certificateInstall.title": "Install Local Certificate",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "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.",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "Continue",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "Cancel",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad needs the local certificate to be trusted before it can open self-signed HTTPS remote windows.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Conectando...",
|
||||
"folderSelection.servers.dialog.errorRequired": "El nombre y la URL del servidor son obligatorios.",
|
||||
"folderSelection.servers.dialog.errorConnect": "No se pudo conectar al servidor remoto.",
|
||||
"folderSelection.servers.certificateInstall.title": "Instalar certificado local",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad necesita instalar un certificado local para abrir ventanas remotas HTTPS autofirmadas. Este certificado solo se usa para el trafico del proxy local de escritorio en tu equipo. Es posible que tu sistema operativo muestre un segundo aviso de certificado despues de esto.",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "Continuar",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "Cancelar",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad necesita que el certificado local sea de confianza antes de poder abrir ventanas remotas HTTPS autofirmadas.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Connexion...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Le nom du serveur et l'URL sont requis.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Impossible de se connecter au serveur distant.",
|
||||
"folderSelection.servers.certificateInstall.title": "Installer le certificat local",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad doit installer un certificat local pour ouvrir des fenetres distantes HTTPS auto-signees. Ce certificat est utilise uniquement pour le trafic du proxy local de bureau sur votre machine. Votre systeme d'exploitation peut afficher une seconde invite de certificat apres cela.",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "Continuer",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "Annuler",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad a besoin que le certificat local soit approuve avant de pouvoir ouvrir des fenetres distantes HTTPS auto-signees.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "מתחבר...",
|
||||
"folderSelection.servers.dialog.errorRequired": "שם השרת והכתובת הם שדות חובה.",
|
||||
"folderSelection.servers.dialog.errorConnect": "לא ניתן היה להתחבר לשרת המרוחק.",
|
||||
"folderSelection.servers.certificateInstall.title": "התקנת אישור מקומי",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad צריך להתקין אישור מקומי כדי לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית. האישור הזה משמש רק לתעבורת ה-proxy המקומי של האפליקציה במחשב שלך. ייתכן שמערכת ההפעלה תציג לאחר מכן בקשת אישור נוספת.",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "המשך",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "ביטול",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad צריך שהאישור המקומי יהיה מהימן לפני שיוכל לפתוח חלונות HTTPS מרוחקים עם אישור בחתימה עצמית.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "接続中...",
|
||||
"folderSelection.servers.dialog.errorRequired": "サーバー名と URL は必須です。",
|
||||
"folderSelection.servers.dialog.errorConnect": "リモートサーバーに接続できませんでした。",
|
||||
"folderSelection.servers.certificateInstall.title": "ローカル証明書をインストール",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad は自己署名 HTTPS のリモートウィンドウを開くために、ローカル証明書をインストールする必要があります。この証明書は、このマシン上のローカルデスクトッププロキシ通信にのみ使用されます。この後、OS が追加の証明書プロンプトを表示する場合があります。",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "続行",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "キャンセル",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "自己署名 HTTPS のリモートウィンドウを開くには、CodeNomad のローカル証明書を信頼する必要があります。",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "Подключение...",
|
||||
"folderSelection.servers.dialog.errorRequired": "Имя сервера и URL обязательны.",
|
||||
"folderSelection.servers.dialog.errorConnect": "Не удалось подключиться к удаленному серверу.",
|
||||
"folderSelection.servers.certificateInstall.title": "Установить локальный сертификат",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad должен установить локальный сертификат, чтобы открывать удаленные HTTPS-окна с самоподписанным сертификатом. Этот сертификат используется только для трафика локального настольного прокси на вашем устройстве. После этого ваша операционная система может показать второе предупреждение о сертификате.",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "Продолжить",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "Отмена",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad должен доверять локальному сертификату, прежде чем сможет открывать удаленные HTTPS-окна с самоподписанным сертификатом.",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -69,5 +69,10 @@ export const folderSelectionMessages = {
|
||||
"folderSelection.servers.dialog.connecting": "连接中...",
|
||||
"folderSelection.servers.dialog.errorRequired": "服务器名称和 URL 为必填项。",
|
||||
"folderSelection.servers.dialog.errorConnect": "无法连接到远程服务器。",
|
||||
"folderSelection.servers.certificateInstall.title": "安装本地证书",
|
||||
"folderSelection.servers.certificateInstall.confirmMessage": "CodeNomad 需要安装本地证书,才能打开使用自签名 HTTPS 的远程窗口。此证书仅用于你这台设备上的本地桌面代理流量。之后你的操作系统可能还会显示第二个证书提示。",
|
||||
"folderSelection.servers.certificateInstall.confirmLabel": "继续",
|
||||
"folderSelection.servers.certificateInstall.cancelLabel": "取消",
|
||||
"folderSelection.servers.certificateInstall.cancelled": "CodeNomad 需要先信任本地证书,才能打开使用自签名 HTTPS 的远程窗口。",
|
||||
"folderSelection.sidecars.button": "Open SideCar",
|
||||
} as const
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { canRestartCli, isElectronHost, isTauriHost } from "../runtime-env"
|
||||
import { getLogger } from "../logger"
|
||||
const log = getLogger("actions")
|
||||
|
||||
|
||||
export async function restartCli(): Promise<boolean> {
|
||||
if (!canRestartCli()) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise<unknown> } }).electronAPI
|
||||
if (api?.restartCli) {
|
||||
await api.restartCli()
|
||||
@@ -15,7 +19,7 @@ export async function restartCli(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
if (typeof window.__TAURI__?.core?.invoke === "function") {
|
||||
await invoke("cli_restart")
|
||||
return true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { getLogger } from "../logger"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { canUseDesktopFolderDrop, isElectronHost, isTauriHost, runtimeEnv } from "../runtime-env"
|
||||
|
||||
const log = getLogger("actions")
|
||||
|
||||
@@ -21,7 +21,7 @@ function getFilePath(file: File): string | null {
|
||||
if (typeof file.path === "string" && file.path.trim().length > 0) {
|
||||
return file.path
|
||||
}
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
const electronPath = (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.getPathForFile?.(file)
|
||||
if (typeof electronPath === "string" && electronPath.trim().length > 0) {
|
||||
return electronPath
|
||||
@@ -44,7 +44,7 @@ async function resolveElectronDirectoryPaths(paths: string[]): Promise<string[]>
|
||||
}
|
||||
|
||||
export function supportsDesktopFolderDrop(): boolean {
|
||||
return runtimeEnv.platform === "desktop" && runtimeEnv.host !== "web"
|
||||
return runtimeEnv.platform === "desktop" && canUseDesktopFolderDrop()
|
||||
}
|
||||
|
||||
export function containsFileDrop(event: DragEvent): boolean {
|
||||
@@ -97,14 +97,14 @@ export async function normalizeDroppedDirectoryPaths(paths: string[]): Promise<s
|
||||
if (uniquePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
return resolveElectronDirectoryPaths(uniquePaths)
|
||||
}
|
||||
return uniquePaths
|
||||
}
|
||||
|
||||
export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => void): Promise<() => void> {
|
||||
if (runtimeEnv.host !== "tauri") {
|
||||
if (!isTauriHost()) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ export async function listenForNativeFolderDrops(onDrop: (paths: string[]) => vo
|
||||
}
|
||||
|
||||
export async function listenForNativeFolderDropState(onState: (state: NativeFolderDropState) => void): Promise<() => void> {
|
||||
if (runtimeEnv.host !== "tauri") {
|
||||
if (!isTauriHost()) {
|
||||
return () => {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { canUseNativeDialogs, isElectronHost, isTauriHost } from "../runtime-env"
|
||||
import type { NativeDialogOptions } from "./types"
|
||||
import { openElectronNativeDialog } from "./electron/functions"
|
||||
import { openTauriNativeDialog } from "./tauri/functions"
|
||||
@@ -6,20 +6,23 @@ import { openTauriNativeDialog } from "./tauri/functions"
|
||||
export type { NativeDialogOptions, NativeDialogFilter, NativeDialogMode } from "./types"
|
||||
|
||||
function resolveNativeHandler(): ((options: NativeDialogOptions) => Promise<string | null>) | null {
|
||||
switch (runtimeEnv.host) {
|
||||
case "electron":
|
||||
return openElectronNativeDialog
|
||||
case "tauri":
|
||||
return openTauriNativeDialog
|
||||
default:
|
||||
return null
|
||||
if (isElectronHost()) {
|
||||
return openElectronNativeDialog
|
||||
}
|
||||
if (isTauriHost()) {
|
||||
return openTauriNativeDialog
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function supportsNativeDialogs(): boolean {
|
||||
return resolveNativeHandler() !== null
|
||||
}
|
||||
|
||||
export function supportsNativeDialogsInCurrentWindow(): boolean {
|
||||
return canUseNativeDialogs()
|
||||
}
|
||||
|
||||
async function openNativeDialog(options: NativeDialogOptions): Promise<string | null> {
|
||||
const handler = resolveNativeHandler()
|
||||
if (!handler) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import type { RemoteServerProfile } from "../../../../server/src/api-types"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { showConfirmDialog } from "../../stores/alerts"
|
||||
import { tGlobal } from "../i18n"
|
||||
import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env"
|
||||
|
||||
export interface RemoteWindowOpenPayload {
|
||||
id: string
|
||||
@@ -16,6 +18,10 @@ export async function openRemoteServerWindow(
|
||||
entryUrl?: string,
|
||||
proxySessionId?: string,
|
||||
): Promise<void> {
|
||||
if (!canOpenRemoteWindows()) {
|
||||
throw new Error("Remote server windows can only be opened from a local desktop window")
|
||||
}
|
||||
|
||||
const payload: RemoteWindowOpenPayload = {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
@@ -25,7 +31,7 @@ export async function openRemoteServerWindow(
|
||||
skipTlsVerify: profile.skipTlsVerify,
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
const api = (window as Window & { electronAPI?: ElectronAPI }).electronAPI
|
||||
if (typeof api?.openRemoteWindow === "function") {
|
||||
await api.openRemoteWindow(payload)
|
||||
@@ -33,7 +39,29 @@ export async function openRemoteServerWindow(
|
||||
}
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
const requiresLocalCertificate =
|
||||
proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://")
|
||||
|
||||
if (requiresLocalCertificate) {
|
||||
const needsInstall = await invoke<boolean>("needs_local_certificate_install")
|
||||
if (needsInstall) {
|
||||
const accepted = await showConfirmDialog(
|
||||
tGlobal("folderSelection.servers.certificateInstall.confirmMessage"),
|
||||
{
|
||||
title: tGlobal("folderSelection.servers.certificateInstall.title"),
|
||||
variant: "warning",
|
||||
confirmLabel: tGlobal("folderSelection.servers.certificateInstall.confirmLabel"),
|
||||
cancelLabel: tGlobal("folderSelection.servers.certificateInstall.cancelLabel"),
|
||||
},
|
||||
)
|
||||
|
||||
if (!accepted) {
|
||||
throw new Error(tGlobal("folderSelection.servers.certificateInstall.cancelled"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await invoke("open_remote_window", { payload })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { isElectronHost, isTauriHost } from "../runtime-env"
|
||||
import { getLogger } from "../logger"
|
||||
|
||||
const log = getLogger("actions")
|
||||
@@ -56,11 +56,11 @@ async function setWebWakeLock(enabled: boolean): Promise<boolean> {
|
||||
|
||||
function hasAnyWakeLockSupport(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
const api = (window as any).electronAPI
|
||||
if (api?.setWakeLock) return true
|
||||
}
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
return typeof window.__TAURI__?.core?.invoke === "function"
|
||||
}
|
||||
return Boolean((navigator as any)?.wakeLock?.request)
|
||||
@@ -106,13 +106,13 @@ async function setTauriWakeLock(enabled: boolean): Promise<boolean> {
|
||||
async function applyWakeLock(enabled: boolean): Promise<boolean> {
|
||||
if (typeof window === "undefined") return false
|
||||
|
||||
if (runtimeEnv.host === "electron") {
|
||||
if (isElectronHost()) {
|
||||
const ok = await setElectronWakeLock(enabled)
|
||||
if (ok || !enabled) return ok
|
||||
// fallback to web API if electron preload didn't expose it
|
||||
}
|
||||
|
||||
if (runtimeEnv.host === "tauri") {
|
||||
if (isTauriHost()) {
|
||||
const ok = await setTauriWakeLock(enabled)
|
||||
if (ok || !enabled) return ok
|
||||
// fallback to web API if tauri command isn't available
|
||||
|
||||
@@ -2,10 +2,12 @@ import { getLogger } from "./logger"
|
||||
|
||||
export type HostRuntime = "electron" | "tauri" | "web"
|
||||
export type PlatformKind = "desktop" | "mobile"
|
||||
export type WindowContextKind = "local" | "remote"
|
||||
|
||||
export interface RuntimeEnvironment {
|
||||
host: HostRuntime
|
||||
platform: PlatformKind
|
||||
windowContext: WindowContextKind
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -14,6 +16,7 @@ declare global {
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CODENOMAD_WINDOW_CONTEXT__?: WindowContextKind
|
||||
electronAPI?: unknown
|
||||
__TAURI__?: {
|
||||
core?: TauriCoreModule
|
||||
@@ -21,11 +24,41 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
function detectWindowContext(): WindowContextKind {
|
||||
if (typeof window === "undefined") {
|
||||
return "remote"
|
||||
}
|
||||
|
||||
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "remote") {
|
||||
return "remote"
|
||||
}
|
||||
|
||||
if (window.__CODENOMAD_WINDOW_CONTEXT__ === "local") {
|
||||
return "local"
|
||||
}
|
||||
|
||||
const win = window as Window & { electronAPI?: unknown }
|
||||
if (typeof win.electronAPI !== "undefined" || typeof win.__TAURI__ !== "undefined") {
|
||||
return "local"
|
||||
}
|
||||
|
||||
if (typeof navigator !== "undefined" && /tauri/i.test(navigator.userAgent)) {
|
||||
return "local"
|
||||
}
|
||||
|
||||
return "remote"
|
||||
}
|
||||
|
||||
function detectHost(): HostRuntime {
|
||||
if (typeof window === "undefined") {
|
||||
return "web"
|
||||
}
|
||||
|
||||
const explicitHost = window.__CODENOMAD_RUNTIME_HOST__
|
||||
if (explicitHost) {
|
||||
return explicitHost
|
||||
}
|
||||
|
||||
const win = window as Window & { electronAPI?: unknown }
|
||||
if (typeof win.electronAPI !== "undefined") {
|
||||
return "electron"
|
||||
@@ -71,16 +104,24 @@ export function detectRuntimeEnvironment(): RuntimeEnvironment {
|
||||
cachedEnv = {
|
||||
host: detectHost(),
|
||||
platform: detectPlatform(),
|
||||
windowContext: detectWindowContext(),
|
||||
}
|
||||
if (typeof window !== "undefined") {
|
||||
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform}`)
|
||||
log.info(`[runtime] host=${cachedEnv.host} platform=${cachedEnv.platform} context=${cachedEnv.windowContext}`)
|
||||
}
|
||||
return cachedEnv
|
||||
}
|
||||
|
||||
export const runtimeEnv = detectRuntimeEnvironment()
|
||||
|
||||
export const isElectronHost = () => runtimeEnv.host === "electron"
|
||||
export const isTauriHost = () => runtimeEnv.host === "tauri"
|
||||
export const isWebHost = () => runtimeEnv.host === "web"
|
||||
export const isMobilePlatform = () => runtimeEnv.platform === "mobile"
|
||||
export const isElectronHost = () => detectHost() === "electron"
|
||||
export const isTauriHost = () => detectHost() === "tauri"
|
||||
export const isWebHost = () => detectHost() === "web"
|
||||
export const isDesktopHost = () => isElectronHost() || isTauriHost()
|
||||
export const isMobilePlatform = () => detectPlatform() === "mobile"
|
||||
export const isLocalWindow = () => detectWindowContext() === "local"
|
||||
export const isRemoteWindow = () => detectWindowContext() === "remote"
|
||||
export const canUseNativeDialogs = () => isDesktopHost() && isLocalWindow()
|
||||
export const canOpenRemoteWindows = () => isDesktopHost() && isLocalWindow()
|
||||
export const canRestartCli = () => isDesktopHost() && isLocalWindow()
|
||||
export const canUseDesktopFolderDrop = () => isDesktopHost() && isLocalWindow()
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
} from "../../stores/preferences"
|
||||
import type { Command } from "../commands"
|
||||
import { tGlobal } from "../i18n"
|
||||
import { runtimeEnv } from "../runtime-env"
|
||||
import { isWebHost } from "../runtime-env"
|
||||
|
||||
export type BehaviorSettingKind = "toggle" | "enum"
|
||||
|
||||
@@ -84,7 +84,7 @@ export function getBehaviorSettings(actions: BehaviorRegistryActions): BehaviorS
|
||||
next,
|
||||
)
|
||||
},
|
||||
disabled: () => runtimeEnv.host === "web",
|
||||
disabled: () => isWebHost(),
|
||||
},
|
||||
{
|
||||
kind: "toggle",
|
||||
@@ -337,13 +337,13 @@ export function getBehaviorCommands(actions: BehaviorRegistryActions): Command[]
|
||||
),
|
||||
description: () =>
|
||||
tGlobal(
|
||||
runtimeEnv.host === "web"
|
||||
isWebHost()
|
||||
? "commands.keyboardShortcutHints.description.disabledWeb"
|
||||
: "commands.keyboardShortcutHints.description",
|
||||
),
|
||||
category: "System",
|
||||
keywords: () => splitKeywords("commands.keyboardShortcutHints.keywords"),
|
||||
disabled: () => runtimeEnv.host === "web",
|
||||
disabled: () => isWebHost(),
|
||||
action: actions.toggleKeyboardShortcutHints,
|
||||
},
|
||||
{
|
||||
|
||||
12
packages/ui/src/types/global.d.ts
vendored
12
packages/ui/src/types/global.d.ts
vendored
@@ -63,10 +63,12 @@ declare global {
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__CODENOMAD_API_BASE__?: string
|
||||
__CODENOMAD_EVENTS_URL__?: string
|
||||
electronAPI?: ElectronAPI
|
||||
__TAURI__?: TauriBridge
|
||||
codenomadLogger?: LoggerControls
|
||||
__CODENOMAD_API_BASE__?: string
|
||||
__CODENOMAD_EVENTS_URL__?: string
|
||||
__CODENOMAD_RUNTIME_HOST__?: "electron" | "tauri" | "web"
|
||||
__CODENOMAD_WINDOW_CONTEXT__?: "local" | "remote"
|
||||
electronAPI?: ElectronAPI
|
||||
__TAURI__?: TauriBridge
|
||||
codenomadLogger?: LoggerControls
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user