Compare commits

...

4 Commits

Author SHA1 Message Date
Shantur Rathore
68551f6731 fix(ui): unify apply_patch diagnostics matching 2026-04-20 21:08:33 +01:00
Shantur Rathore
662a6b94b0 fix(ui): remove delete shortcuts from recent lists 2026-04-20 20:51:36 +01:00
Pascal André
77df40169a Fix WSL UNC OpenCode binaries on Windows (#341)
## Summary
- support Windows validation and launch of OpenCode binaries stored
under WSL UNC paths like \\wsl.localhost\...
- harden the existing manual directory browser so absolute, UNC, and WSL
paths can be pasted and navigated reliably
- harden WSL env/path propagation, UNC workspace handling, runtime
shutdown, and add targeted tests

Partially addresses #5.

## Testing
- node --test --import tsx src/workspaces/__tests__/spawn.test.ts
- npm run typecheck --workspace @neuralnomads/codenomad
- npm run typecheck --workspace @codenomad/ui
2026-04-20 20:29:08 +01:00
Shantur Rathore
3b411e2e73 fix(ui): gate desktop privileges by host and window context (#347)
Don't let remote server windows use local features like local file browser etc
2026-04-20 20:28:11 +01:00
25 changed files with 965 additions and 452 deletions

View File

@@ -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"],
},
})

View File

@@ -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))

View File

@@ -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"

View 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"])
})
})

View File

@@ -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
}

View 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`
}

View File

@@ -40,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";
@@ -300,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)
@@ -542,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());

View File

@@ -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

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"),
})

View File

@@ -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":

View File

@@ -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)

View File

@@ -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">

View 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
}

View File

@@ -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

View File

@@ -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 () => {}
}

View File

@@ -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) {

View File

@@ -2,7 +2,7 @@ import { invoke } from "@tauri-apps/api/core"
import type { RemoteServerProfile } from "../../../../server/src/api-types"
import { showConfirmDialog } from "../../stores/alerts"
import { tGlobal } from "../i18n"
import { runtimeEnv } from "../runtime-env"
import { canOpenRemoteWindows, isElectronHost, isTauriHost } from "../runtime-env"
export interface RemoteWindowOpenPayload {
id: string
@@ -18,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,
@@ -27,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)
@@ -35,7 +39,7 @@ export async function openRemoteServerWindow(
}
}
if (runtimeEnv.host === "tauri") {
if (isTauriHost()) {
const requiresLocalCertificate =
proxySessionId !== undefined && (entryUrl ?? profile.baseUrl).startsWith("https://")

View File

@@ -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

View File

@@ -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()

View File

@@ -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,
},
{

View File

@@ -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
}
}