Compare commits
2 Commits
fix_local_
...
v0.13.1-de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55a6479c0e | ||
|
|
f88064af06 |
1
packages/electron-app/.gitignore
vendored
1
packages/electron-app/.gitignore
vendored
@@ -2,3 +2,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
release/
|
release/
|
||||||
.vite/
|
.vite/
|
||||||
|
electron/resources/server/
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
import { BrowserWindow, Notification, dialog, ipcMain, powerSaveBlocker, type OpenDialogOptions } from "electron"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
|
import { requestMicrophoneAccess } from "./permissions"
|
||||||
import type { CliProcessManager, CliStatus } from "./process-manager"
|
import type { CliProcessManager, CliStatus } from "./process-manager"
|
||||||
|
|
||||||
let wakeLockId: number | null = null
|
let wakeLockId: number | null = null
|
||||||
@@ -111,6 +112,11 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan
|
|||||||
return { enabled: false }
|
return { enabled: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
"media:requestMicrophoneAccess",
|
||||||
|
async (): Promise<{ granted: boolean }> => ({ granted: await requestMicrophoneAccess() }),
|
||||||
|
)
|
||||||
|
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
"notifications:show",
|
"notifications:show",
|
||||||
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
async (_event, payload: { title?: unknown; body?: unknown }): Promise<{ ok: boolean; reason?: string }> => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { dirname, join } from "path"
|
|||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
import { createApplicationMenu } from "./menu"
|
import { createApplicationMenu } from "./menu"
|
||||||
import { setupCliIPC } from "./ipc"
|
import { setupCliIPC } from "./ipc"
|
||||||
|
import { configureMediaPermissionHandlers } from "./permissions"
|
||||||
import { CliProcessManager } from "./process-manager"
|
import { CliProcessManager } from "./process-manager"
|
||||||
|
|
||||||
const mainFilename = fileURLToPath(import.meta.url)
|
const mainFilename = fileURLToPath(import.meta.url)
|
||||||
@@ -327,6 +328,7 @@ function finalizeCliSwap(url: string) {
|
|||||||
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = "codenomad_session"
|
||||||
let bootstrapExchangeInFlight = false
|
let bootstrapExchangeInFlight = false
|
||||||
|
|
||||||
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null {
|
||||||
@@ -349,7 +351,6 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<boolean> {
|
||||||
const sessionCookieName = cliManager.getAuthCookieName()
|
|
||||||
const target = new URL("/api/auth/token", baseUrl)
|
const target = new URL("/api/auth/token", baseUrl)
|
||||||
const body = JSON.stringify({ token })
|
const body = JSON.stringify({ token })
|
||||||
|
|
||||||
@@ -380,14 +381,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise<b
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = extractCookieValue(result.setCookie, sessionCookieName)
|
const sessionId = extractCookieValue(result.setCookie, SESSION_COOKIE_NAME)
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
await session.defaultSession.cookies.set({
|
await session.defaultSession.cookies.set({
|
||||||
url: baseUrl,
|
url: baseUrl,
|
||||||
name: sessionCookieName,
|
name: SESSION_COOKIE_NAME,
|
||||||
value: sessionId,
|
value: sessionId,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -489,6 +490,7 @@ app.whenReady().then(() => {
|
|||||||
|
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
session.defaultSession.setSpellCheckerEnabled(false)
|
session.defaultSession.setSpellCheckerEnabled(false)
|
||||||
|
configureMediaPermissionHandlers(getAllowedRendererOrigins)
|
||||||
app.on("browser-window-created", (_, window) => {
|
app.on("browser-window-created", (_, window) => {
|
||||||
window.webContents.session.setSpellCheckerEnabled(false)
|
window.webContents.session.setSpellCheckerEnabled(false)
|
||||||
})
|
})
|
||||||
|
|||||||
58
packages/electron-app/electron/main/permissions.ts
Normal file
58
packages/electron-app/electron/main/permissions.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { session, systemPreferences } from "electron"
|
||||||
|
|
||||||
|
const isMac = process.platform === "darwin"
|
||||||
|
|
||||||
|
export function isAllowedRendererOrigin(origin: string | undefined | null, allowedOrigins: string[]): boolean {
|
||||||
|
if (!origin) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const normalized = new URL(origin).origin
|
||||||
|
return allowedOrigins.includes(normalized)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function configureMediaPermissionHandlers(getAllowedOrigins: () => string[]) {
|
||||||
|
const isAudioMediaRequest = (permission: string, details?: unknown) => {
|
||||||
|
if (permission !== "media") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaTypes = (details as { mediaTypes?: string[] } | undefined)?.mediaTypes ?? []
|
||||||
|
return mediaTypes.length === 0 || mediaTypes.includes("audio")
|
||||||
|
}
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
|
||||||
|
if (!isAudioMediaRequest(permission, details)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins())
|
||||||
|
})
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback, details) => {
|
||||||
|
if (!isAudioMediaRequest(permission, details)) {
|
||||||
|
callback(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestingOrigin = (details as { requestingOrigin?: string } | undefined)?.requestingOrigin || webContents.getURL()
|
||||||
|
callback(isAllowedRendererOrigin(requestingOrigin, getAllowedOrigins()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestMicrophoneAccess(): Promise<boolean> {
|
||||||
|
if (!isMac) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = systemPreferences.getMediaAccessStatus("microphone")
|
||||||
|
if (status === "granted") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return systemPreferences.askForMediaAccess("microphone")
|
||||||
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
import { spawn, spawnSync, type ChildProcess } from "child_process"
|
||||||
import { app } from "electron"
|
import { app, utilityProcess, type UtilityProcess } from "electron"
|
||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import { EventEmitter } from "events"
|
import { EventEmitter } from "events"
|
||||||
import { existsSync, readFileSync } from "fs"
|
import { existsSync, readFileSync } from "fs"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
import { parse as parseYaml } from "yaml"
|
import { parse as parseYaml } from "yaml"
|
||||||
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell"
|
||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
const nodeRequire = createRequire(import.meta.url)
|
||||||
|
const mainFilename = fileURLToPath(import.meta.url)
|
||||||
|
const mainDirname = path.dirname(mainFilename)
|
||||||
|
|
||||||
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:"
|
||||||
const SESSION_COOKIE_NAME_PREFIX = "codenomad_session"
|
|
||||||
|
|
||||||
type CliState = "starting" | "ready" | "error" | "stopped"
|
type CliState = "starting" | "ready" | "error" | "stopped"
|
||||||
type ListeningMode = "local" | "all"
|
type ListeningMode = "local" | "all"
|
||||||
@@ -39,6 +41,9 @@ interface CliEntryResolution {
|
|||||||
runnerPath?: string
|
runnerPath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ManagedChild = ChildProcess | UtilityProcess
|
||||||
|
type ChildLaunchMode = "spawn" | "utility"
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json"
|
||||||
|
|
||||||
function isYamlPath(filePath: string): boolean {
|
function isYamlPath(filePath: string): boolean {
|
||||||
@@ -118,12 +123,12 @@ export declare interface CliProcessManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class CliProcessManager extends EventEmitter {
|
export class CliProcessManager extends EventEmitter {
|
||||||
private child?: ChildProcess
|
private child?: ManagedChild
|
||||||
|
private childLaunchMode: ChildLaunchMode = "spawn"
|
||||||
private status: CliStatus = { state: "stopped" }
|
private status: CliStatus = { state: "stopped" }
|
||||||
private stdoutBuffer = ""
|
private stdoutBuffer = ""
|
||||||
private stderrBuffer = ""
|
private stderrBuffer = ""
|
||||||
private bootstrapToken: string | null = null
|
private bootstrapToken: string | null = null
|
||||||
private authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
|
||||||
private requestedStop = false
|
private requestedStop = false
|
||||||
|
|
||||||
async start(options: StartOptions): Promise<CliStatus> {
|
async start(options: StartOptions): Promise<CliStatus> {
|
||||||
@@ -134,37 +139,66 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.stdoutBuffer = ""
|
this.stdoutBuffer = ""
|
||||||
this.stderrBuffer = ""
|
this.stderrBuffer = ""
|
||||||
this.bootstrapToken = null
|
this.bootstrapToken = null
|
||||||
this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}`
|
|
||||||
this.requestedStop = false
|
this.requestedStop = false
|
||||||
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined })
|
||||||
|
|
||||||
const cliEntry = this.resolveCliEntry(options)
|
|
||||||
const listeningMode = this.resolveListeningMode()
|
const listeningMode = this.resolveListeningMode()
|
||||||
const host = resolveHostForMode(listeningMode)
|
const host = resolveHostForMode(listeningMode)
|
||||||
const args = this.buildCliArgs(options, host)
|
const args = this.buildCliArgs(options, host)
|
||||||
|
|
||||||
console.info(
|
let child: ManagedChild
|
||||||
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
|
||||||
)
|
|
||||||
|
|
||||||
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
if (this.shouldUsePackagedShellSupervisor(options)) {
|
||||||
env.ELECTRON_RUN_AS_NODE = "1"
|
const runtimePath = this.resolveShellNodeCommand()
|
||||||
|
const entryPath = this.resolveBundledProdEntry()
|
||||||
|
const supervisorPath = this.resolveCliSupervisorPath()
|
||||||
|
const shellEnv = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
|
const shellCommand = buildUserShellCommand(`exec ${this.buildExecutableCommand(runtimePath, [entryPath, ...args])}`)
|
||||||
|
const supervisorPayload = JSON.stringify({
|
||||||
|
command: shellCommand.command,
|
||||||
|
args: shellCommand.args,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
})
|
||||||
|
|
||||||
const spawnDetails = supportsUserShell()
|
console.info(
|
||||||
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) via utility supervisor using node at ${runtimePath} (host=${host})`,
|
||||||
: this.buildDirectSpawn(cliEntry, args)
|
)
|
||||||
|
console.info(`[cli] utility supervisor: ${supervisorPath}`)
|
||||||
|
console.info(`[cli] shell command: ${shellCommand.command} ${shellCommand.args.join(" ")}`)
|
||||||
|
|
||||||
const detached = process.platform !== "win32"
|
child = utilityProcess.fork(supervisorPath, [supervisorPayload], {
|
||||||
const child = spawn(spawnDetails.command, spawnDetails.args, {
|
env: shellEnv,
|
||||||
cwd: process.cwd(),
|
stdio: "pipe",
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
serviceName: "CodeNomad CLI Supervisor",
|
||||||
env,
|
})
|
||||||
shell: false,
|
this.childLaunchMode = "utility"
|
||||||
detached,
|
} else {
|
||||||
})
|
const cliEntry = this.resolveCliEntry(options)
|
||||||
|
console.info(
|
||||||
|
`[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`,
|
||||||
|
)
|
||||||
|
|
||||||
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
const env = supportsUserShell() ? getUserShellEnv() : { ...process.env }
|
||||||
if (!child.pid) {
|
env.ELECTRON_RUN_AS_NODE = "1"
|
||||||
|
|
||||||
|
const spawnDetails = supportsUserShell()
|
||||||
|
? buildUserShellCommand(`ELECTRON_RUN_AS_NODE=1 exec ${this.buildCommand(cliEntry, args)}`)
|
||||||
|
: this.buildDirectSpawn(cliEntry, args)
|
||||||
|
|
||||||
|
const detached = process.platform !== "win32"
|
||||||
|
child = spawn(spawnDetails.command, spawnDetails.args, {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
env,
|
||||||
|
shell: false,
|
||||||
|
detached,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.info(`[cli] spawn command: ${spawnDetails.command} ${spawnDetails.args.join(" ")}`)
|
||||||
|
this.childLaunchMode = "spawn"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.childLaunchMode === "spawn" && !child.pid) {
|
||||||
console.error("[cli] spawn failed: no pid")
|
console.error("[cli] spawn failed: no pid")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,23 +213,48 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
this.handleStream(data.toString(), "stderr")
|
this.handleStream(data.toString(), "stderr")
|
||||||
})
|
})
|
||||||
|
|
||||||
child.on("error", (error) => {
|
if (this.childLaunchMode === "utility") {
|
||||||
console.error("[cli] failed to start CLI:", error)
|
const utilityChild = child as UtilityProcess
|
||||||
this.updateStatus({ state: "error", error: error.message })
|
|
||||||
this.emit("error", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
child.on("exit", (code, signal) => {
|
utilityChild.on("error", (error) => {
|
||||||
const failed = this.status.state !== "ready"
|
const message = this.describeUtilityProcessError(error)
|
||||||
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
console.error("[cli] utility supervisor failed:", error)
|
||||||
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
this.updateStatus({ state: "error", error: message })
|
||||||
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
this.emit("error", new Error(message))
|
||||||
if (failed && error) {
|
})
|
||||||
this.emit("error", new Error(error))
|
|
||||||
}
|
utilityChild.on("exit", (code) => {
|
||||||
this.emit("exit", this.status)
|
const failed = this.status.state !== "ready"
|
||||||
this.child = undefined
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}` : undefined
|
||||||
})
|
console.info(`[cli] exit (code=${code ?? ""})${error ? ` error=${error}` : ""}`)
|
||||||
|
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||||
|
if (failed && error) {
|
||||||
|
this.emit("error", new Error(error))
|
||||||
|
}
|
||||||
|
this.emit("exit", this.status)
|
||||||
|
this.child = undefined
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const spawnedChild = child as ChildProcess
|
||||||
|
|
||||||
|
spawnedChild.on("error", (error) => {
|
||||||
|
console.error("[cli] failed to start CLI:", error)
|
||||||
|
this.updateStatus({ state: "error", error: error.message })
|
||||||
|
this.emit("error", error)
|
||||||
|
})
|
||||||
|
|
||||||
|
spawnedChild.on("exit", (code, signal) => {
|
||||||
|
const failed = this.status.state !== "ready"
|
||||||
|
const error = failed ? this.status.error ?? `CLI exited with code ${code ?? 0}${signal ? ` (${signal})` : ""}` : undefined
|
||||||
|
console.info(`[cli] exit (code=${code}, signal=${signal || ""})${error ? ` error=${error}` : ""}`)
|
||||||
|
this.updateStatus({ state: failed ? "error" : "stopped", error })
|
||||||
|
if (failed && error) {
|
||||||
|
this.emit("error", new Error(error))
|
||||||
|
}
|
||||||
|
this.emit("exit", this.status)
|
||||||
|
this.child = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise<CliStatus>((resolve, reject) => {
|
return new Promise<CliStatus>((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -222,16 +281,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.childLaunchMode === "utility") {
|
||||||
|
return this.stopUtilityChild(child as UtilityProcess)
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnedChild = child as ChildProcess
|
||||||
|
|
||||||
this.requestedStop = true
|
this.requestedStop = true
|
||||||
|
|
||||||
const pid = child.pid
|
const pid = spawnedChild.pid
|
||||||
if (!pid) {
|
if (!pid) {
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
this.updateStatus({ state: "stopped" })
|
this.updateStatus({ state: "stopped" })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAlreadyExited = () => child.exitCode !== null || child.signalCode !== null
|
const isAlreadyExited = () => spawnedChild.exitCode !== null || spawnedChild.signalCode !== null
|
||||||
|
|
||||||
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
const tryKillPosixGroup = (signal: NodeJS.Signals) => {
|
||||||
try {
|
try {
|
||||||
@@ -307,7 +372,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
sendStopSignal("SIGKILL")
|
sendStopSignal("SIGKILL")
|
||||||
}, 30000)
|
}, 30000)
|
||||||
|
|
||||||
child.on("exit", () => {
|
spawnedChild.on("exit", () => {
|
||||||
clearTimeout(killTimeout)
|
clearTimeout(killTimeout)
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
console.info("[cli] CLI process exited")
|
console.info("[cli] CLI process exited")
|
||||||
@@ -327,12 +392,48 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getStatus(): CliStatus {
|
private stopUtilityChild(child: UtilityProcess): Promise<void> {
|
||||||
return { ...this.status }
|
this.requestedStop = true
|
||||||
|
|
||||||
|
const pid = child.pid
|
||||||
|
if (!pid) {
|
||||||
|
this.child = undefined
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const killTimeout = setTimeout(() => {
|
||||||
|
console.warn(`[cli] stop timed out after 30000ms; sending SIGKILL (pid=${pid})`)
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
child.once("exit", () => {
|
||||||
|
clearTimeout(killTimeout)
|
||||||
|
this.child = undefined
|
||||||
|
console.info("[cli] CLI process exited")
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
if (child.pid === undefined) {
|
||||||
|
clearTimeout(killTimeout)
|
||||||
|
this.child = undefined
|
||||||
|
this.updateStatus({ state: "stopped" })
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
child.kill()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getAuthCookieName(): string {
|
getStatus(): CliStatus {
|
||||||
return this.authCookieName
|
return { ...this.status }
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveListeningMode(): ListeningMode {
|
private resolveListeningMode(): ListeningMode {
|
||||||
@@ -342,14 +443,22 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
private handleTimeout() {
|
private handleTimeout() {
|
||||||
if (this.child) {
|
if (this.child) {
|
||||||
const pid = this.child.pid
|
const pid = this.child.pid
|
||||||
if (pid && process.platform !== "win32") {
|
if (this.childLaunchMode === "utility") {
|
||||||
|
if (pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (pid && process.platform !== "win32") {
|
||||||
try {
|
try {
|
||||||
process.kill(-pid, "SIGKILL")
|
process.kill(-pid, "SIGKILL")
|
||||||
} catch {
|
} catch {
|
||||||
this.child.kill("SIGKILL")
|
;(this.child as ChildProcess).kill("SIGKILL")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.child.kill("SIGKILL")
|
;(this.child as ChildProcess).kill("SIGKILL")
|
||||||
}
|
}
|
||||||
this.child = undefined
|
this.child = undefined
|
||||||
}
|
}
|
||||||
@@ -423,7 +532,7 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildCliArgs(options: StartOptions, host: string): string[] {
|
private buildCliArgs(options: StartOptions, host: string): string[] {
|
||||||
const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName]
|
const args = ["serve", "--host", host, "--generate-token"]
|
||||||
|
|
||||||
if (options.dev) {
|
if (options.dev) {
|
||||||
// Dev: run plain HTTP + Vite dev server proxy.
|
// Dev: run plain HTTP + Vite dev server proxy.
|
||||||
@@ -456,6 +565,10 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
return parts.join(" ")
|
return parts.join(" ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildExecutableCommand(command: string, args: string[]): string {
|
||||||
|
return [JSON.stringify(command), ...args.map((arg) => JSON.stringify(arg))].join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
private buildDirectSpawn(cliEntry: CliEntryResolution, args: string[]) {
|
||||||
if (cliEntry.runner === "tsx") {
|
if (cliEntry.runner === "tsx") {
|
||||||
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
return { command: process.execPath, args: [cliEntry.runnerPath!, cliEntry.entry, ...args] }
|
||||||
@@ -526,4 +639,58 @@ export class CliProcessManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
throw new Error("Unable to locate CodeNomad CLI build (dist/bin.js). Run npm run build --workspace @neuralnomads/codenomad.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldUsePackagedShellSupervisor(options: StartOptions): boolean {
|
||||||
|
return !options.dev && app.isPackaged && process.platform === "darwin"
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCliSupervisorPath(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.resourcesPath, "cli-supervisor.cjs"),
|
||||||
|
path.join(mainDirname, "../resources/cli-supervisor.cjs"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unable to locate CodeNomad CLI supervisor script.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveShellNodeCommand(): string {
|
||||||
|
const configured = process.env.NODE_BINARY?.trim()
|
||||||
|
return configured && configured.length > 0 ? configured : "node"
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveBundledProdEntry(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.join(process.resourcesPath, "server", "dist", "bin.js"),
|
||||||
|
path.join(mainDirname, "../resources/server/dist/bin.js"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (existsSync(candidate)) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unable to locate bundled CodeNomad CLI build in app resources.")
|
||||||
|
}
|
||||||
|
|
||||||
|
private describeUtilityProcessError(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message) {
|
||||||
|
return error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const typed = error as { type?: unknown; location?: unknown }
|
||||||
|
if (typeof typed.type === "string") {
|
||||||
|
return typeof typed.location === "string" ? `${typed.type} at ${typed.location}` : typed.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const electronAPI = {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
requestMicrophoneAccess: () => ipcRenderer.invoke("media:requestMicrophoneAccess"),
|
||||||
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
setWakeLock: (enabled) => ipcRenderer.invoke("power:setWakeLock", Boolean(enabled)),
|
||||||
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
showNotification: (payload) => ipcRenderer.invoke("notifications:show", payload),
|
||||||
}
|
}
|
||||||
|
|||||||
131
packages/electron-app/electron/resources/cli-supervisor.cjs
Normal file
131
packages/electron-app/electron/resources/cli-supervisor.cjs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const { spawn } = require("child_process")
|
||||||
|
|
||||||
|
const SHUTDOWN_GRACE_MS = 30_000
|
||||||
|
|
||||||
|
let child = null
|
||||||
|
let shutdownTimer = null
|
||||||
|
|
||||||
|
function log(message, error) {
|
||||||
|
if (error) {
|
||||||
|
console.error(`[cli-supervisor] ${message}`, error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log(`[cli-supervisor] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearShutdownTimer() {
|
||||||
|
if (shutdownTimer) {
|
||||||
|
clearTimeout(shutdownTimer)
|
||||||
|
shutdownTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardStream(stream, target) {
|
||||||
|
if (!stream) return
|
||||||
|
stream.on("data", (chunk) => {
|
||||||
|
target.write(chunk)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminateChild(force) {
|
||||||
|
if (!child || child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
child.kill(force ? "SIGKILL" : "SIGTERM")
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestShutdown(force = false) {
|
||||||
|
if (!child) {
|
||||||
|
process.exit(force ? 1 : 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
terminateChild(force)
|
||||||
|
if (force) {
|
||||||
|
process.exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearShutdownTimer()
|
||||||
|
shutdownTimer = setTimeout(() => {
|
||||||
|
log(`shutdown timed out after ${SHUTDOWN_GRACE_MS}ms; forcing child termination`)
|
||||||
|
terminateChild(true)
|
||||||
|
}, SHUTDOWN_GRACE_MS)
|
||||||
|
shutdownTimer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
function installShutdownHandlers() {
|
||||||
|
process.on("SIGTERM", () => requestShutdown(false))
|
||||||
|
process.on("SIGINT", () => requestShutdown(false))
|
||||||
|
process.on("disconnect", () => requestShutdown(false))
|
||||||
|
process.on("uncaughtException", (error) => {
|
||||||
|
log("uncaught exception", error)
|
||||||
|
requestShutdown(true)
|
||||||
|
})
|
||||||
|
process.on("unhandledRejection", (error) => {
|
||||||
|
log("unhandled rejection", error)
|
||||||
|
requestShutdown(true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePayload() {
|
||||||
|
const raw = process.argv[2]
|
||||||
|
if (!raw) {
|
||||||
|
throw new Error("Supervisor payload is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
throw new Error("Supervisor payload must be an object")
|
||||||
|
}
|
||||||
|
if (typeof parsed.command !== "string" || parsed.command.trim().length === 0) {
|
||||||
|
throw new Error("Supervisor payload command is required")
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed.args) || !parsed.args.every((value) => typeof value === "string")) {
|
||||||
|
throw new Error("Supervisor payload args must be a string array")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: parsed.command,
|
||||||
|
args: parsed.args,
|
||||||
|
cwd: typeof parsed.cwd === "string" && parsed.cwd.trim().length > 0 ? parsed.cwd : process.cwd(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
installShutdownHandlers()
|
||||||
|
|
||||||
|
const payload = parsePayload()
|
||||||
|
log(`launching shell command: ${payload.command} ${payload.args.join(" ")}`)
|
||||||
|
|
||||||
|
child = spawn(payload.command, payload.args, {
|
||||||
|
cwd: payload.cwd,
|
||||||
|
env: process.env,
|
||||||
|
shell: false,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
})
|
||||||
|
|
||||||
|
forwardStream(child.stdout, process.stdout)
|
||||||
|
forwardStream(child.stderr, process.stderr)
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
|
log("failed to spawn shell command", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
clearShutdownTimer()
|
||||||
|
log(`child exited code=${code ?? ""} signal=${signal ?? ""}`)
|
||||||
|
process.exitCode = typeof code === "number" ? code : signal ? 1 : 0
|
||||||
|
process.exit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
"dev:debug": "cross-env CLI_LOG_LEVEL=debug electron-vite dev",
|
||||||
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
"dev:trace": "cross-env CLI_LOG_LEVEL=trace electron-vite dev",
|
||||||
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
"dev:electron": "NODE_ENV=development ELECTRON_ENABLE_LOGGING=1 NODE_OPTIONS=\"--import tsx\" electron electron/main/main.ts",
|
||||||
|
"prepare:resources": "node scripts/prepare-resources.js",
|
||||||
|
"prebuild": "npm run prepare:resources",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||||
"preview": "electron-vite preview",
|
"preview": "electron-vite preview",
|
||||||
@@ -33,8 +35,11 @@
|
|||||||
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
"build:linux-arm64": "node scripts/build.js linux-arm64",
|
||||||
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
"build:linux-rpm": "node scripts/build.js linux-rpm",
|
||||||
"build:all": "node scripts/build.js all",
|
"build:all": "node scripts/build.js all",
|
||||||
|
"prepackage:mac": "npm run prepare:resources",
|
||||||
"package:mac": "electron-builder --mac",
|
"package:mac": "electron-builder --mac",
|
||||||
|
"prepackage:win": "npm run prepare:resources",
|
||||||
"package:win": "electron-builder --win",
|
"package:win": "electron-builder --win",
|
||||||
|
"prepackage:linux": "npm run prepare:resources",
|
||||||
"package:linux": "electron-builder --linux"
|
"package:linux": "electron-builder --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -82,6 +87,12 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"mac": {
|
"mac": {
|
||||||
|
"entitlements": "electron/resources/entitlements.mac.plist",
|
||||||
|
"entitlementsInherit": "electron/resources/entitlements.mac.plist",
|
||||||
|
"extendInfo": {
|
||||||
|
"NSMicrophoneUsageDescription": "CodeNomad needs microphone access for speech-to-text prompt input.",
|
||||||
|
"NSLocalNetworkUsageDescription": "CodeNomad needs local network access to connect to locally hosted AI and speech services."
|
||||||
|
},
|
||||||
"category": "public.app-category.developer-tools",
|
"category": "public.app-category.developer-tools",
|
||||||
"target": [
|
"target": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -111,6 +111,12 @@ async function build(platform) {
|
|||||||
env: { NODE_PATH: workspaceNodeModulesPath },
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log("\n📦 Step 1.5/3: Preparing packaged server resources...\n")
|
||||||
|
await run(process.execPath, [join(appDir, "scripts", "prepare-resources.js")], {
|
||||||
|
cwd: workspaceRoot,
|
||||||
|
env: { NODE_PATH: workspaceNodeModulesPath },
|
||||||
|
})
|
||||||
|
|
||||||
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
console.log("\n📦 Step 2/3: Building Electron app...\n")
|
||||||
await run(npmCmd, ["run", "build"])
|
await run(npmCmd, ["run", "build"])
|
||||||
|
|
||||||
|
|||||||
132
packages/electron-app/scripts/prepare-resources.js
Normal file
132
packages/electron-app/scripts/prepare-resources.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import fs from "fs"
|
||||||
|
import path, { join } from "path"
|
||||||
|
import { spawnSync } from "child_process"
|
||||||
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
|
const __dirname = fileURLToPath(new URL(".", import.meta.url))
|
||||||
|
const appDir = join(__dirname, "..")
|
||||||
|
const workspaceRoot = join(appDir, "..", "..")
|
||||||
|
const serverRoot = join(appDir, "..", "server")
|
||||||
|
const resourcesRoot = join(appDir, "electron", "resources")
|
||||||
|
const serverDest = join(resourcesRoot, "server")
|
||||||
|
const npmExecPath = process.env.npm_execpath
|
||||||
|
const npmNodeExecPath = process.env.npm_node_execpath
|
||||||
|
|
||||||
|
const serverSources = ["dist", "public", "node_modules", "package.json"]
|
||||||
|
const serverDepsMarker = join(serverRoot, "node_modules", "fastify", "package.json")
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
console.log(`[prepare-resources] ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServerBuild() {
|
||||||
|
const distPath = join(serverRoot, "dist")
|
||||||
|
const publicPath = join(serverRoot, "public")
|
||||||
|
if (!fs.existsSync(distPath) || !fs.existsSync(publicPath)) {
|
||||||
|
throw new Error("Server build artifacts are missing. Run the server build before packaging Electron.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureServerDependencies() {
|
||||||
|
if (fs.existsSync(serverDepsMarker)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log("installing production server dependencies")
|
||||||
|
const npmArgs = [
|
||||||
|
"install",
|
||||||
|
"--omit=dev",
|
||||||
|
"--ignore-scripts",
|
||||||
|
"--workspaces=false",
|
||||||
|
"--package-lock=false",
|
||||||
|
"--install-strategy=shallow",
|
||||||
|
"--fund=false",
|
||||||
|
"--audit=false",
|
||||||
|
]
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${join(workspaceRoot, "node_modules", ".bin")}${path.delimiter}${process.env.PATH ?? ""}`,
|
||||||
|
npm_config_workspaces: "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
const npmCli = npmExecPath && npmNodeExecPath ? [npmNodeExecPath, [npmExecPath, ...npmArgs]] : null
|
||||||
|
const result = npmCli
|
||||||
|
? spawnSync(npmCli[0], npmCli[1], { cwd: serverRoot, stdio: "inherit", env })
|
||||||
|
: spawnSync("npm", npmArgs, { cwd: serverRoot, stdio: "inherit", env, shell: process.platform === "win32" })
|
||||||
|
|
||||||
|
if (result.status !== 0) {
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error
|
||||||
|
}
|
||||||
|
throw new Error(`npm install exited with code ${result.status ?? 1}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyServerArtifacts() {
|
||||||
|
fs.rmSync(serverDest, { recursive: true, force: true })
|
||||||
|
fs.mkdirSync(serverDest, { recursive: true })
|
||||||
|
|
||||||
|
for (const name of serverSources) {
|
||||||
|
const from = join(serverRoot, name)
|
||||||
|
const to = join(serverDest, name)
|
||||||
|
if (!fs.existsSync(from)) {
|
||||||
|
throw new Error(`Missing required server artifact: ${from}`)
|
||||||
|
}
|
||||||
|
fs.cpSync(from, to, { recursive: true, dereference: true })
|
||||||
|
log(`copied ${name} to Electron resources`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripNodeModuleBins() {
|
||||||
|
const root = join(serverDest, "node_modules")
|
||||||
|
if (!fs.existsSync(root)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = [root]
|
||||||
|
let removed = 0
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()
|
||||||
|
if (!current) break
|
||||||
|
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = join(current, entry.name)
|
||||||
|
if (entry.name === ".bin") {
|
||||||
|
fs.rmSync(full, { recursive: true, force: true })
|
||||||
|
removed += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
stack.push(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
log(`removed ${removed} node_modules/.bin directories`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
ensureServerBuild()
|
||||||
|
ensureServerDependencies()
|
||||||
|
copyServerArtifacts()
|
||||||
|
stripNodeModuleBins()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("[prepare-resources] failed:", error)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
"include": ["electron/**/*.ts", "electron.vite.config.ts"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "electron/resources/server"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,16 @@ export interface AuthManagerInit {
|
|||||||
password?: string
|
password?: string
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
dangerouslySkipAuth?: boolean
|
dangerouslySkipAuth?: boolean
|
||||||
cookieName?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AuthManager {
|
export class AuthManager {
|
||||||
private readonly authStore: AuthStore | null
|
private readonly authStore: AuthStore | null
|
||||||
private readonly tokenManager: TokenManager | null
|
private readonly tokenManager: TokenManager | null
|
||||||
private readonly sessionManager = new SessionManager()
|
private readonly sessionManager = new SessionManager()
|
||||||
private readonly cookieName: string
|
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME
|
||||||
private readonly authEnabled: boolean
|
private readonly authEnabled: boolean
|
||||||
|
|
||||||
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
|
||||||
this.cookieName = sanitizeCookieName(init.cookieName)
|
|
||||||
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
this.authEnabled = !Boolean(init.dangerouslySkipAuth)
|
||||||
|
|
||||||
if (!this.authEnabled) {
|
if (!this.authEnabled) {
|
||||||
@@ -141,16 +139,6 @@ export class AuthManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeCookieName(value: string | undefined): string {
|
|
||||||
const trimmed = value?.trim()
|
|
||||||
if (!trimmed) {
|
|
||||||
return DEFAULT_AUTH_COOKIE_NAME
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
|
|
||||||
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAuthFilePath(configPath: string) {
|
function resolveAuthFilePath(configPath: string) {
|
||||||
const resolvedConfigPath = resolvePath(configPath)
|
const resolvedConfigPath = resolvePath(configPath)
|
||||||
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
return path.join(path.dirname(resolvedConfigPath), "auth.json")
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
|
|||||||
import { createLogger } from "./logger"
|
import { createLogger } from "./logger"
|
||||||
import { launchInBrowser } from "./launcher"
|
import { launchInBrowser } from "./launcher"
|
||||||
import { resolveUi } from "./ui/remote-ui"
|
import { resolveUi } from "./ui/remote-ui"
|
||||||
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager"
|
||||||
import { resolveHttpsOptions } from "./server/tls"
|
import { resolveHttpsOptions } from "./server/tls"
|
||||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "./server/network-addresses"
|
import { resolveNetworkAddresses } from "./server/network-addresses"
|
||||||
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
|
||||||
import { SpeechService } from "./speech/service"
|
import { SpeechService } from "./speech/service"
|
||||||
|
|
||||||
@@ -55,7 +55,6 @@ interface CliOptions {
|
|||||||
launch: boolean
|
launch: boolean
|
||||||
authUsername: string
|
authUsername: string
|
||||||
authPassword?: string
|
authPassword?: string
|
||||||
authCookieName: string
|
|
||||||
generateToken: boolean
|
generateToken: boolean
|
||||||
dangerouslySkipAuth: boolean
|
dangerouslySkipAuth: boolean
|
||||||
}
|
}
|
||||||
@@ -101,11 +100,6 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
.default(DEFAULT_AUTH_USERNAME),
|
.default(DEFAULT_AUTH_USERNAME),
|
||||||
)
|
)
|
||||||
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
|
||||||
.addOption(
|
|
||||||
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
|
|
||||||
.env("CODENOMAD_AUTH_COOKIE_NAME")
|
|
||||||
.default(DEFAULT_AUTH_COOKIE_NAME),
|
|
||||||
)
|
|
||||||
.addOption(
|
.addOption(
|
||||||
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
|
||||||
.env("CODENOMAD_GENERATE_TOKEN")
|
.env("CODENOMAD_GENERATE_TOKEN")
|
||||||
@@ -145,7 +139,6 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
launch?: boolean
|
launch?: boolean
|
||||||
username: string
|
username: string
|
||||||
password?: string
|
password?: string
|
||||||
authCookieName: string
|
|
||||||
generateToken?: boolean
|
generateToken?: boolean
|
||||||
dangerouslySkipAuth?: boolean
|
dangerouslySkipAuth?: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -192,7 +185,6 @@ function parseCliOptions(argv: string[]): CliOptions {
|
|||||||
launch: Boolean(parsed.launch),
|
launch: Boolean(parsed.launch),
|
||||||
authUsername: parsed.username,
|
authUsername: parsed.username,
|
||||||
authPassword: parsed.password,
|
authPassword: parsed.password,
|
||||||
authCookieName: parsed.authCookieName,
|
|
||||||
generateToken: Boolean(parsed.generateToken),
|
generateToken: Boolean(parsed.generateToken),
|
||||||
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
|
||||||
}
|
}
|
||||||
@@ -274,7 +266,6 @@ async function main() {
|
|||||||
configPath: configLocation.configYamlPath,
|
configPath: configLocation.configYamlPath,
|
||||||
username: options.authUsername,
|
username: options.authUsername,
|
||||||
password: options.authPassword,
|
password: options.authPassword,
|
||||||
cookieName: options.authCookieName,
|
|
||||||
generateToken: options.generateToken,
|
generateToken: options.generateToken,
|
||||||
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
dangerouslySkipAuth: options.dangerouslySkipAuth,
|
||||||
},
|
},
|
||||||
@@ -451,22 +442,18 @@ async function main() {
|
|||||||
// which can lead clients to talk to the wrong process.
|
// which can lead clients to talk to the wrong process.
|
||||||
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
const localUrl = `${localProtocol}://127.0.0.1:${localStart.port}`
|
||||||
let remoteUrl: string | undefined
|
let remoteUrl: string | undefined
|
||||||
let remoteAddresses = [] as ReturnType<typeof resolveNetworkAddresses>
|
|
||||||
if (remoteStart) {
|
if (remoteStart) {
|
||||||
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
const wantsAll = options.host === "0.0.0.0" || !isLoopbackHost(options.host)
|
||||||
let remoteHost = options.host
|
let remoteHost = options.host
|
||||||
if (wantsAll) {
|
if (wantsAll) {
|
||||||
if (options.host === "0.0.0.0") {
|
if (options.host === "0.0.0.0") {
|
||||||
const resolved = resolveRemoteAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
const candidates = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: remoteStart.port })
|
||||||
remoteAddresses = resolved.userVisible
|
remoteHost = candidates.find((addr) => addr.scope === "external")?.ip ?? "localhost"
|
||||||
remoteUrl = resolved.primaryRemoteUrl ?? `${remoteProtocol}://localhost:${remoteStart.port}`
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
remoteHost = "localhost"
|
remoteHost = "localhost"
|
||||||
}
|
}
|
||||||
if (!remoteUrl) {
|
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
||||||
remoteUrl = `${remoteProtocol}://${remoteHost}:${remoteStart.port}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
serverMeta.localUrl = localUrl
|
serverMeta.localUrl = localUrl
|
||||||
@@ -477,9 +464,7 @@ async function main() {
|
|||||||
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
serverMeta.listeningMode = options.host === "0.0.0.0" || !isLoopbackHost(options.host) ? "all" : "local"
|
||||||
|
|
||||||
if (serverMeta.remotePort && remoteUrl) {
|
if (serverMeta.remotePort && remoteUrl) {
|
||||||
serverMeta.addresses = remoteAddresses.length
|
serverMeta.addresses = resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
||||||
? remoteAddresses
|
|
||||||
: resolveNetworkAddresses({ host: options.host, protocol: remoteProtocol, port: serverMeta.remotePort })
|
|
||||||
} else {
|
} else {
|
||||||
serverMeta.addresses = []
|
serverMeta.addresses = []
|
||||||
}
|
}
|
||||||
@@ -487,16 +472,6 @@ async function main() {
|
|||||||
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
console.log(`Local Connection URL : ${serverMeta.localUrl}`)
|
||||||
if (serverMeta.remoteUrl) {
|
if (serverMeta.remoteUrl) {
|
||||||
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
console.log(`Remote Connection URL : ${serverMeta.remoteUrl}`)
|
||||||
const additionalRemoteUrls = serverMeta.addresses
|
|
||||||
.map((addr) => addr.remoteUrl)
|
|
||||||
.filter((url) => url !== serverMeta.remoteUrl)
|
|
||||||
|
|
||||||
if (additionalRemoteUrls.length > 0) {
|
|
||||||
console.log("Other Accessible URLs:")
|
|
||||||
for (const url of additionalRemoteUrls) {
|
|
||||||
console.log(` - ${url}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.launch) {
|
if (options.launch) {
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
import assert from "node:assert/strict"
|
|
||||||
import os from "node:os"
|
|
||||||
import { describe, it } from "node:test"
|
|
||||||
|
|
||||||
import { resolveNetworkAddresses, resolveRemoteAddresses } from "../network-addresses"
|
|
||||||
|
|
||||||
describe("resolveNetworkAddresses", () => {
|
|
||||||
it("preserves interface order among external addresses", () => {
|
|
||||||
const addresses = [
|
|
||||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
|
||||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
|
||||||
{ address: "10.0.0.8", family: 4, internal: false },
|
|
||||||
{ address: "127.0.0.1", family: "IPv4", internal: true },
|
|
||||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
|
||||||
]
|
|
||||||
|
|
||||||
usingMockedNetworkInterfaces(addresses, () => {
|
|
||||||
const result = resolveNetworkAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
result.map((entry) => entry.ip),
|
|
||||||
["172.24.0.1", "192.168.1.128", "10.0.0.8", "169.254.10.20", "127.0.0.1"],
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("resolveRemoteAddresses", () => {
|
|
||||||
it("keeps all external addresses user-visible while preferring non-link-local addresses for the primary URL", () => {
|
|
||||||
const addresses = [
|
|
||||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
|
||||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
|
||||||
{ address: "172.24.0.1", family: "IPv4", internal: false },
|
|
||||||
]
|
|
||||||
|
|
||||||
usingMockedNetworkInterfaces(addresses, () => {
|
|
||||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
result.userVisible.map((entry) => entry.ip),
|
|
||||||
["192.168.1.128", "172.24.0.1", "169.254.10.20"],
|
|
||||||
)
|
|
||||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("prefers private LAN addresses over public addresses", () => {
|
|
||||||
const addresses = [
|
|
||||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
|
||||||
{ address: "192.168.1.128", family: "IPv4", internal: false },
|
|
||||||
{ address: "8.8.8.8", family: "IPv4", internal: false },
|
|
||||||
]
|
|
||||||
|
|
||||||
usingMockedNetworkInterfaces(addresses, () => {
|
|
||||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
|
||||||
|
|
||||||
assert.deepEqual(
|
|
||||||
result.userVisible.map((entry) => entry.ip),
|
|
||||||
["192.168.1.128", "203.0.113.40", "8.8.8.8"],
|
|
||||||
)
|
|
||||||
assert.equal(result.primaryRemoteUrl, "https://192.168.1.128:9898")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it("uses a public address when no private LAN address is available", () => {
|
|
||||||
const addresses = [
|
|
||||||
{ address: "169.254.10.20", family: "IPv4", internal: false },
|
|
||||||
{ address: "203.0.113.40", family: "IPv4", internal: false },
|
|
||||||
]
|
|
||||||
|
|
||||||
usingMockedNetworkInterfaces(addresses, () => {
|
|
||||||
const result = resolveRemoteAddresses({ host: "0.0.0.0", protocol: "https", port: 9898 })
|
|
||||||
|
|
||||||
assert.deepEqual(result.userVisible.map((entry) => entry.ip), ["203.0.113.40", "169.254.10.20"])
|
|
||||||
assert.equal(result.primaryRemoteUrl, "https://203.0.113.40:9898")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
function usingMockedNetworkInterfaces(
|
|
||||||
addresses: Array<{ address: string; family: string | number; internal: boolean }>,
|
|
||||||
callback: () => void,
|
|
||||||
) {
|
|
||||||
const original = os.networkInterfaces
|
|
||||||
os.networkInterfaces = (() => ({
|
|
||||||
ethernet0: addresses as unknown as ReturnType<typeof os.networkInterfaces>[string],
|
|
||||||
})) as typeof os.networkInterfaces
|
|
||||||
|
|
||||||
try {
|
|
||||||
callback()
|
|
||||||
} finally {
|
|
||||||
os.networkInterfaces = original
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import type { NetworkAddress } from "../api-types"
|
import type { NetworkAddress } from "../api-types"
|
||||||
|
|
||||||
export interface ResolvedRemoteAddresses {
|
|
||||||
all: NetworkAddress[]
|
|
||||||
userVisible: NetworkAddress[]
|
|
||||||
primaryRemoteUrl?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveNetworkAddresses(args: {
|
export function resolveNetworkAddresses(args: {
|
||||||
host: string
|
host: string
|
||||||
protocol: "http" | "https"
|
protocol: "http" | "https"
|
||||||
@@ -64,57 +58,10 @@ export function resolveNetworkAddresses(args: {
|
|||||||
return results.sort((a, b) => {
|
return results.sort((a, b) => {
|
||||||
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope]
|
||||||
if (scopeDelta !== 0) return scopeDelta
|
if (scopeDelta !== 0) return scopeDelta
|
||||||
|
return a.ip.localeCompare(b.ip)
|
||||||
return 0
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveRemoteAddresses(args: {
|
|
||||||
host: string
|
|
||||||
protocol: "http" | "https"
|
|
||||||
port: number
|
|
||||||
}): ResolvedRemoteAddresses {
|
|
||||||
const all = resolveNetworkAddresses(args)
|
|
||||||
const userVisible = sortUserVisibleAddresses(all.filter((address) => address.scope === "external"))
|
|
||||||
return {
|
|
||||||
all,
|
|
||||||
userVisible,
|
|
||||||
primaryRemoteUrl: userVisible[0]?.remoteUrl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortUserVisibleAddresses(addresses: NetworkAddress[]): NetworkAddress[] {
|
|
||||||
return [...addresses].sort((left, right) => getUserVisiblePriority(left.ip) - getUserVisiblePriority(right.ip))
|
|
||||||
}
|
|
||||||
|
|
||||||
function getUserVisiblePriority(ip: string): number {
|
|
||||||
if (isPrivateIPv4(ip)) return 0
|
|
||||||
if (isLinkLocalIPv4(ip)) return 2
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLinkLocalIPv4(ip: string): boolean {
|
|
||||||
const octets = parseIPv4(ip)
|
|
||||||
if (!octets) return false
|
|
||||||
const [first, second] = octets
|
|
||||||
return first === 169 && second === 254
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPrivateIPv4(ip: string): boolean {
|
|
||||||
const octets = parseIPv4(ip)
|
|
||||||
if (!octets) return false
|
|
||||||
const [first, second] = octets
|
|
||||||
|
|
||||||
if (first === 10) return true
|
|
||||||
if (first === 192 && second === 168) return true
|
|
||||||
return first === 172 && second >= 16 && second <= 31
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseIPv4(value: string): number[] | null {
|
|
||||||
if (!isIPv4Address(value)) return null
|
|
||||||
return value.split(".").map((part) => Number(part))
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIPv4Address(value: string | undefined): value is string {
|
function isIPv4Address(value: string | undefined): value is string {
|
||||||
if (!value) return false
|
if (!value) return false
|
||||||
const parts = value.split(".")
|
const parts = value.split(".")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from "fastify"
|
import { FastifyInstance } from "fastify"
|
||||||
import { ServerMeta } from "../../api-types"
|
import { ServerMeta } from "../../api-types"
|
||||||
|
import { resolveNetworkAddresses } from "../network-addresses"
|
||||||
|
|
||||||
interface RouteDeps {
|
interface RouteDeps {
|
||||||
serverMeta: ServerMeta
|
serverMeta: ServerMeta
|
||||||
@@ -13,12 +13,14 @@ export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) {
|
|||||||
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
function buildMetaResponse(meta: ServerMeta): ServerMeta {
|
||||||
const localPort = resolveLocalPort(meta)
|
const localPort = resolveLocalPort(meta)
|
||||||
const remote = resolveRemote(meta)
|
const remote = resolveRemote(meta)
|
||||||
|
const addresses = remote && remote.port > 0 ? resolveNetworkAddresses({ host: meta.host, protocol: remote.protocol, port: remote.port }) : []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...meta,
|
...meta,
|
||||||
localPort,
|
localPort,
|
||||||
remotePort: remote?.port,
|
remotePort: remote?.port,
|
||||||
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
listeningMode: meta.host === "0.0.0.0" || !isLoopbackHost(meta.host) ? "all" : "local",
|
||||||
|
addresses,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,19 +147,49 @@ export class OpenAICompatibleSpeechProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
|
const endpoint = new URL("audio/speech", ensureTrailingSlash(settings.baseUrl ?? "https://api.openai.com/v1"))
|
||||||
const response = await fetch(endpoint, {
|
let response: Response
|
||||||
method: "POST",
|
try {
|
||||||
headers: {
|
response = await fetch(endpoint, {
|
||||||
Authorization: `Bearer ${settings.apiKey}`,
|
method: "POST",
|
||||||
"Content-Type": "application/json",
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${settings.apiKey}`,
|
||||||
body: JSON.stringify({
|
"Content-Type": "application/json",
|
||||||
model: settings.ttsModel,
|
},
|
||||||
voice: settings.ttsVoice,
|
body: JSON.stringify({
|
||||||
input: text,
|
model: settings.ttsModel,
|
||||||
response_format: format,
|
voice: settings.ttsVoice,
|
||||||
}),
|
input: text,
|
||||||
})
|
response_format: format,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
const detailedError = error as Error & {
|
||||||
|
cause?: unknown
|
||||||
|
code?: string
|
||||||
|
errno?: number | string
|
||||||
|
syscall?: string
|
||||||
|
address?: string
|
||||||
|
port?: number
|
||||||
|
}
|
||||||
|
this.options.logger.error(
|
||||||
|
{
|
||||||
|
err: error,
|
||||||
|
endpoint: endpoint.toString(),
|
||||||
|
baseUrl: settings.baseUrl,
|
||||||
|
model: settings.ttsModel,
|
||||||
|
voice: settings.ttsVoice,
|
||||||
|
format,
|
||||||
|
cause: detailedError.cause,
|
||||||
|
code: detailedError.code,
|
||||||
|
errno: detailedError.errno,
|
||||||
|
syscall: detailedError.syscall,
|
||||||
|
address: detailedError.address,
|
||||||
|
port: detailedError.port,
|
||||||
|
},
|
||||||
|
"speech.synthesize fetch failed",
|
||||||
|
)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const detail = await response.text()
|
const detail = await response.text()
|
||||||
|
|||||||
10
packages/tauri-app/src-tauri/Info.plist
Normal file
10
packages/tauri-app/src-tauri/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
<string>CodeNomad needs microphone access for speech-to-text prompt input.</string>
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>CodeNomad needs local network access to connect to locally hosted AI and speech services.</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -16,7 +16,7 @@ use std::process::{Child, Command, Stdio};
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant};
|
||||||
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -48,7 +48,7 @@ fn workspace_root() -> Option<PathBuf> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
|
const SESSION_COOKIE_NAME: &str = "codenomad_session";
|
||||||
|
|
||||||
const CLI_STOP_GRACE_SECS: u64 = 30;
|
const CLI_STOP_GRACE_SECS: u64 = 30;
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
@@ -124,11 +124,7 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
|
|||||||
Some(value.to_string())
|
Some(value.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn exchange_bootstrap_token(
|
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> {
|
||||||
base_url: &str,
|
|
||||||
token: &str,
|
|
||||||
cookie_name: &str,
|
|
||||||
) -> anyhow::Result<Option<String>> {
|
|
||||||
let parsed = Url::parse(base_url)?;
|
let parsed = Url::parse(base_url)?;
|
||||||
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
let host = parsed.host_str().unwrap_or("127.0.0.1");
|
||||||
let port = parsed.port_or_known_default().unwrap_or(80);
|
let port = parsed.port_or_known_default().unwrap_or(80);
|
||||||
@@ -163,11 +159,11 @@ fn exchange_bootstrap_token(
|
|||||||
for line in lines {
|
for line in lines {
|
||||||
// handle case-insensitive header name
|
// handle case-insensitive header name
|
||||||
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
if let Some(value) = line.strip_prefix("Set-Cookie:") {
|
||||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||||
return Ok(Some(session_id));
|
return Ok(Some(session_id));
|
||||||
}
|
}
|
||||||
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
} else if let Some(value) = line.strip_prefix("set-cookie:") {
|
||||||
if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
|
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) {
|
||||||
return Ok(Some(session_id));
|
return Ok(Some(session_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,16 +172,11 @@ fn exchange_bootstrap_token(
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_session_cookie(
|
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> {
|
||||||
app: &AppHandle,
|
|
||||||
base_url: &str,
|
|
||||||
cookie_name: &str,
|
|
||||||
session_id: &str,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
let parsed = Url::parse(base_url)?;
|
let parsed = Url::parse(base_url)?;
|
||||||
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
|
||||||
|
|
||||||
let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
|
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id))
|
||||||
.domain(domain)
|
.domain(domain)
|
||||||
.path("/")
|
.path("/")
|
||||||
.http_only(true)
|
.http_only(true)
|
||||||
@@ -199,16 +190,6 @@ fn set_session_cookie(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn generate_auth_cookie_name() -> String {
|
|
||||||
let pid = std::process::id();
|
|
||||||
let timestamp = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|duration| duration.as_millis())
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -522,8 +503,7 @@ impl CliProcessManager {
|
|||||||
"resolved CLI entry runner={:?} entry={} host={}",
|
"resolved CLI entry runner={:?} entry={} host={}",
|
||||||
resolution.runner, resolution.entry, host
|
resolution.runner, resolution.entry, host
|
||||||
));
|
));
|
||||||
let auth_cookie_name = Arc::new(generate_auth_cookie_name());
|
let args = resolution.build_args(dev, &host);
|
||||||
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
|
|
||||||
log_line(&format!("CLI args: {:?}", args));
|
log_line(&format!("CLI args: {:?}", args));
|
||||||
if dev {
|
if dev {
|
||||||
log_line("development mode: will prefer tsx + source if present");
|
log_line("development mode: will prefer tsx + source if present");
|
||||||
@@ -604,7 +584,6 @@ impl CliProcessManager {
|
|||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let ready_clone = ready.clone();
|
let ready_clone = ready.clone();
|
||||||
let token_clone = bootstrap_token.clone();
|
let token_clone = bootstrap_token.clone();
|
||||||
let auth_cookie_name_clone = auth_cookie_name.clone();
|
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let stdout = child_clone
|
let stdout = child_clone
|
||||||
@@ -626,7 +605,6 @@ impl CliProcessManager {
|
|||||||
&status_clone,
|
&status_clone,
|
||||||
&ready_clone,
|
&ready_clone,
|
||||||
&token_clone,
|
&token_clone,
|
||||||
auth_cookie_name_clone.as_str(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if let Some(reader) = stderr {
|
if let Some(reader) = stderr {
|
||||||
@@ -637,7 +615,6 @@ impl CliProcessManager {
|
|||||||
&status_clone,
|
&status_clone,
|
||||||
&ready_clone,
|
&ready_clone,
|
||||||
&token_clone,
|
&token_clone,
|
||||||
auth_cookie_name_clone.as_str(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -754,7 +731,6 @@ impl CliProcessManager {
|
|||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
auth_cookie_name: &str,
|
|
||||||
) {
|
) {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
|
||||||
@@ -790,14 +766,7 @@ impl CliProcessManager {
|
|||||||
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
.and_then(|re| re.captures(line).and_then(|c| c.get(1)))
|
||||||
.map(|m| m.as_str().to_string())
|
.map(|m| m.as_str().to_string())
|
||||||
{
|
{
|
||||||
Self::mark_ready(
|
Self::mark_ready(app, status, ready, bootstrap_token, url);
|
||||||
app,
|
|
||||||
status,
|
|
||||||
ready,
|
|
||||||
bootstrap_token,
|
|
||||||
auth_cookie_name,
|
|
||||||
url,
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,7 +781,6 @@ impl CliProcessManager {
|
|||||||
status,
|
status,
|
||||||
ready,
|
ready,
|
||||||
bootstrap_token,
|
bootstrap_token,
|
||||||
auth_cookie_name,
|
|
||||||
format!("http://localhost:{port}"),
|
format!("http://localhost:{port}"),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -825,7 +793,6 @@ impl CliProcessManager {
|
|||||||
status,
|
status,
|
||||||
ready,
|
ready,
|
||||||
bootstrap_token,
|
bootstrap_token,
|
||||||
auth_cookie_name,
|
|
||||||
format!("http://localhost:{}", port),
|
format!("http://localhost:{}", port),
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@@ -844,7 +811,6 @@ impl CliProcessManager {
|
|||||||
status: &Arc<Mutex<CliStatus>>,
|
status: &Arc<Mutex<CliStatus>>,
|
||||||
ready: &Arc<AtomicBool>,
|
ready: &Arc<AtomicBool>,
|
||||||
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
bootstrap_token: &Arc<Mutex<Option<String>>>,
|
||||||
auth_cookie_name: &str,
|
|
||||||
base_url: String,
|
base_url: String,
|
||||||
) {
|
) {
|
||||||
ready.store(true, Ordering::SeqCst);
|
ready.store(true, Ordering::SeqCst);
|
||||||
@@ -868,11 +834,9 @@ impl CliProcessManager {
|
|||||||
if scheme.as_deref() != Some("http") {
|
if scheme.as_deref() != Some("http") {
|
||||||
navigate_main(app, &base_url);
|
navigate_main(app, &base_url);
|
||||||
} else {
|
} else {
|
||||||
match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
|
match exchange_bootstrap_token(&base_url, &token) {
|
||||||
Ok(Some(session_id)) => {
|
Ok(Some(session_id)) => {
|
||||||
if let Err(err) =
|
if let Err(err) = set_session_cookie(app, &base_url, &session_id) {
|
||||||
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
|
|
||||||
{
|
|
||||||
log_line(&format!("failed to set session cookie: {err}"));
|
log_line(&format!("failed to set session cookie: {err}"));
|
||||||
navigate_main(app, &format!("{base_url}/login"));
|
navigate_main(app, &format!("{base_url}/login"));
|
||||||
} else {
|
} else {
|
||||||
@@ -968,13 +932,11 @@ impl CliEntry {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
|
fn build_args(&self, dev: bool, host: &str) -> Vec<String> {
|
||||||
let mut args = vec![
|
let mut args = vec![
|
||||||
"serve".to_string(),
|
"serve".to_string(),
|
||||||
"--host".to_string(),
|
"--host".to_string(),
|
||||||
host.to_string(),
|
host.to_string(),
|
||||||
"--auth-cookie-name".to_string(),
|
|
||||||
auth_cookie_name.to_string(),
|
|
||||||
"--generate-token".to_string(),
|
"--generate-token".to_string(),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { showAlertDialog } from "../../stores/alerts"
|
|||||||
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
|
import { loadSpeechCapabilities, speechCapabilities } from "../../stores/speech"
|
||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
|
import { isElectronHost } from "../../lib/runtime-env"
|
||||||
|
|
||||||
interface UsePromptVoiceInputOptions {
|
interface UsePromptVoiceInputOptions {
|
||||||
prompt: Accessor<string>
|
prompt: Accessor<string>
|
||||||
@@ -88,6 +89,14 @@ export function usePromptVoiceInput(options: UsePromptVoiceInputOptions) {
|
|||||||
try {
|
try {
|
||||||
recordedChunks = []
|
recordedChunks = []
|
||||||
shouldTranscribe = true
|
shouldTranscribe = true
|
||||||
|
|
||||||
|
if (isElectronHost()) {
|
||||||
|
const granted = await (window as Window & { electronAPI?: ElectronAPI }).electronAPI?.requestMicrophoneAccess?.()
|
||||||
|
if (granted && !granted.granted) {
|
||||||
|
throw new Error(t("promptInput.voiceInput.error.permissionDenied"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
mediaRecorder = createRecorder(mediaStream)
|
mediaRecorder = createRecorder(mediaStream)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Dialog } from "@kobalte/core/dialog"
|
|||||||
import { Switch } from "@kobalte/core/switch"
|
import { Switch } from "@kobalte/core/switch"
|
||||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||||
import { toDataURL } from "qrcode"
|
import { toDataURL } from "qrcode"
|
||||||
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||||
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types"
|
||||||
import { serverApi } from "../lib/api-client"
|
import { serverApi } from "../lib/api-client"
|
||||||
import { restartCli } from "../lib/native/cli"
|
import { restartCli } from "../lib/native/cli"
|
||||||
@@ -10,7 +10,6 @@ import { serverSettings, setListeningMode } from "../stores/preferences"
|
|||||||
import { showConfirmDialog } from "../stores/alerts"
|
import { showConfirmDialog } from "../stores/alerts"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
import { useI18n } from "../lib/i18n"
|
import { useI18n } from "../lib/i18n"
|
||||||
import { splitRemoteAddresses, type RemoteAddressGroups } from "../lib/remote-access-addresses"
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
|
|
||||||
@@ -33,17 +32,17 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
const displayAddresses = createMemo(() => {
|
||||||
const list = addresses()
|
const list = addresses()
|
||||||
if (!allowExternalConnections()) {
|
if (!allowExternalConnections()) {
|
||||||
return { recommended: null, hidden: [] }
|
return []
|
||||||
}
|
}
|
||||||
return splitRemoteAddresses(list)
|
// Local URL is displayed separately; list only remote-friendly addresses.
|
||||||
|
return list.filter((address) => address.scope !== "loopback")
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshMeta = async () => {
|
const refreshMeta = async () => {
|
||||||
@@ -54,7 +53,6 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||||
setMeta(metaResult)
|
setMeta(metaResult)
|
||||||
setAuthStatus(authResult)
|
setAuthStatus(authResult)
|
||||||
setShowAllAddresses(false)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -327,7 +325,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
|
|
||||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||||
<Show when={displayAddresses().recommended || meta()?.localUrl} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
<Show when={displayAddresses().length > 0} fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}>
|
||||||
<div class="remote-address-list">
|
<div class="remote-address-list">
|
||||||
<Show when={meta()?.localUrl}>
|
<Show when={meta()?.localUrl}>
|
||||||
{(url) => {
|
{(url) => {
|
||||||
@@ -374,9 +372,8 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={displayAddresses().recommended}>
|
<For each={displayAddresses()}>
|
||||||
{(addressAccessor) => {
|
{(address) => {
|
||||||
const address = addressAccessor()
|
|
||||||
const url = address.remoteUrl
|
const url = address.remoteUrl
|
||||||
const expandedState = () => expandedUrl() === url
|
const expandedState = () => expandedUrl() === url
|
||||||
const qr = () => qrCodes()[url]
|
const qr = () => qrCodes()[url]
|
||||||
@@ -386,14 +383,13 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
: address.scope === "loopback"
|
: address.scope === "loopback"
|
||||||
? t("remoteAccess.address.scope.loopback")
|
? t("remoteAccess.address.scope.loopback")
|
||||||
: t("remoteAccess.address.scope.internal")
|
: t("remoteAccess.address.scope.internal")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="remote-address">
|
<div class="remote-address">
|
||||||
<div class="remote-address-main">
|
<div class="remote-address-main">
|
||||||
<div>
|
<div>
|
||||||
<p class="remote-address-url">{url}</p>
|
<p class="remote-address-url">{url}</p>
|
||||||
<p class="remote-address-meta">
|
<p class="remote-address-meta">
|
||||||
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="remote-actions">
|
<div class="remote-actions">
|
||||||
@@ -428,83 +424,7 @@ export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</For>
|
||||||
|
|
||||||
<Show when={displayAddresses().hidden.length > 0}>
|
|
||||||
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
|
||||||
<button
|
|
||||||
class="remote-address-disclosure-trigger"
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
|
||||||
aria-expanded={showAllAddresses()}
|
|
||||||
>
|
|
||||||
<span class="remote-address-disclosure-label">
|
|
||||||
{showAllAddresses()
|
|
||||||
? t("remoteAccess.addresses.actions.hideOther")
|
|
||||||
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
|
||||||
</span>
|
|
||||||
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Show when={showAllAddresses()}>
|
|
||||||
<div class="remote-address-disclosure-content">
|
|
||||||
<For each={displayAddresses().hidden}>
|
|
||||||
{(address) => {
|
|
||||||
const url = address.remoteUrl
|
|
||||||
const expandedState = () => expandedUrl() === url
|
|
||||||
const qr = () => qrCodes()[url]
|
|
||||||
const scopeLabel = () =>
|
|
||||||
address.scope === "external"
|
|
||||||
? t("remoteAccess.address.scope.network")
|
|
||||||
: address.scope === "loopback"
|
|
||||||
? t("remoteAccess.address.scope.loopback")
|
|
||||||
: t("remoteAccess.address.scope.internal")
|
|
||||||
return (
|
|
||||||
<div class="remote-address">
|
|
||||||
<div class="remote-address-main">
|
|
||||||
<div>
|
|
||||||
<p class="remote-address-url">{url}</p>
|
|
||||||
<p class="remote-address-meta">
|
|
||||||
{address.family.toUpperCase()} • {scopeLabel()} • {address.ip}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="remote-actions">
|
|
||||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
|
||||||
<ExternalLink class="remote-icon" />
|
|
||||||
{t("remoteAccess.address.open")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="remote-pill"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void toggleExpanded(url)}
|
|
||||||
aria-expanded={expandedState()}
|
|
||||||
>
|
|
||||||
<Link2 class="remote-icon" />
|
|
||||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={expandedState()}>
|
|
||||||
<div class="remote-qr">
|
|
||||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
|
||||||
{(dataUrl) => (
|
|
||||||
<img
|
|
||||||
src={dataUrl()}
|
|
||||||
alt={t("remoteAccess.address.qrAlt", { url })}
|
|
||||||
class="remote-qr-img"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Switch } from "@kobalte/core/switch"
|
import { Switch } from "@kobalte/core/switch"
|
||||||
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js"
|
||||||
import { toDataURL } from "qrcode"
|
import { toDataURL } from "qrcode"
|
||||||
import { ChevronDown, ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid"
|
||||||
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
import type { NetworkAddress, ServerMeta } from "../../../../server/src/api-types"
|
||||||
import { serverApi } from "../../lib/api-client"
|
import { serverApi } from "../../lib/api-client"
|
||||||
import { restartCli } from "../../lib/native/cli"
|
import { restartCli } from "../../lib/native/cli"
|
||||||
@@ -9,7 +9,6 @@ import { serverSettings, setListeningMode } from "../../stores/preferences"
|
|||||||
import { showConfirmDialog } from "../../stores/alerts"
|
import { showConfirmDialog } from "../../stores/alerts"
|
||||||
import { getLogger } from "../../lib/logger"
|
import { getLogger } from "../../lib/logger"
|
||||||
import { useI18n } from "../../lib/i18n"
|
import { useI18n } from "../../lib/i18n"
|
||||||
import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote-access-addresses"
|
|
||||||
|
|
||||||
const log = getLogger("actions")
|
const log = getLogger("actions")
|
||||||
|
|
||||||
@@ -31,15 +30,14 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
const [passwordConfirm, setPasswordConfirm] = createSignal("")
|
||||||
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
const [passwordError, setPasswordError] = createSignal<string | null>(null)
|
||||||
const [savingPassword, setSavingPassword] = createSignal(false)
|
const [savingPassword, setSavingPassword] = createSignal(false)
|
||||||
const [showAllAddresses, setShowAllAddresses] = createSignal(false)
|
|
||||||
|
|
||||||
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
const addresses = createMemo<NetworkAddress[]>(() => meta()?.addresses ?? [])
|
||||||
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode)
|
||||||
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
const allowExternalConnections = createMemo(() => currentMode() === "all")
|
||||||
const displayAddresses = createMemo<RemoteAddressGroups>(() => {
|
const displayAddresses = createMemo(() => {
|
||||||
const list = addresses()
|
const list = addresses()
|
||||||
if (!allowExternalConnections()) return { recommended: null, hidden: [] }
|
if (!allowExternalConnections()) return []
|
||||||
return splitRemoteAddresses(list)
|
return list.filter((address) => address.scope !== "loopback")
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshMeta = async () => {
|
const refreshMeta = async () => {
|
||||||
@@ -50,7 +48,6 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
const [metaResult, authResult] = await Promise.all([serverApi.fetchServerMeta(), serverApi.fetchAuthStatus()])
|
||||||
setMeta(metaResult)
|
setMeta(metaResult)
|
||||||
setAuthStatus(authResult)
|
setAuthStatus(authResult)
|
||||||
setShowAllAddresses(false)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err))
|
setError(err instanceof Error ? err.message : String(err))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -220,35 +217,31 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
fallback={<div class="settings-card-message">{t("remoteAccess.authStatus.unavailable")}</div>}
|
||||||
>
|
>
|
||||||
<div class="settings-card-content">
|
<div class="settings-card-content">
|
||||||
<div class="settings-password-summary-row">
|
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
||||||
<div class="settings-password-summary-copy">
|
<p class="settings-help-text">
|
||||||
<p class="settings-help-text">{t("remoteAccess.username", { username: authStatus()!.username ?? "codenomad" })}</p>
|
{authStatus()!.passwordUserProvided
|
||||||
<p class="settings-help-text">
|
? t("remoteAccess.password.status.set")
|
||||||
{authStatus()!.passwordUserProvided
|
: t("remoteAccess.password.status.unset")}
|
||||||
? t("remoteAccess.password.status.set")
|
</p>
|
||||||
: t("remoteAccess.password.status.unset")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="settings-password-actions">
|
<div class="settings-password-actions">
|
||||||
<button
|
<button
|
||||||
class="settings-pill-button"
|
class="settings-pill-button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setPasswordFormOpen(!passwordFormOpen())
|
setPasswordFormOpen(!passwordFormOpen())
|
||||||
setPasswordError(null)
|
setPasswordError(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{passwordFormOpen()
|
{passwordFormOpen()
|
||||||
? t("remoteAccess.password.actions.cancel")
|
? t("remoteAccess.password.actions.cancel")
|
||||||
: authStatus()!.passwordUserProvided
|
: authStatus()!.passwordUserProvided
|
||||||
? t("remoteAccess.password.actions.change")
|
? t("remoteAccess.password.actions.change")
|
||||||
: t("remoteAccess.password.actions.set")}
|
: t("remoteAccess.password.actions.set")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Show when={passwordFormOpen()}>
|
<Show when={passwordFormOpen()}>
|
||||||
<div class="settings-form-group">
|
<div class="settings-form-group">
|
||||||
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
<label class="settings-form-label">{t("remoteAccess.password.form.newPassword")}</label>
|
||||||
<input
|
<input
|
||||||
@@ -298,7 +291,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
<Show when={!loading()} fallback={<div class="remote-card">{t("remoteAccess.addresses.loading")}</div>}>
|
||||||
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
<Show when={!error()} fallback={<div class="remote-error">{error()}</div>}>
|
||||||
<Show
|
<Show
|
||||||
when={Boolean(displayAddresses().recommended) || meta()?.localUrl}
|
when={displayAddresses().length > 0 || meta()?.localUrl}
|
||||||
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
fallback={<div class="remote-card">{t("remoteAccess.addresses.none")}</div>}
|
||||||
>
|
>
|
||||||
<div class="remote-address-list">
|
<div class="remote-address-list">
|
||||||
@@ -348,9 +341,8 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
}}
|
}}
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={displayAddresses().recommended}>
|
<For each={displayAddresses()}>
|
||||||
{(addressAccessor) => {
|
{(address) => {
|
||||||
const address = addressAccessor()
|
|
||||||
const url = address.remoteUrl
|
const url = address.remoteUrl
|
||||||
const expandedState = () => expandedUrl() === url
|
const expandedState = () => expandedUrl() === url
|
||||||
const qr = () => qrCodes()[url]
|
const qr = () => qrCodes()[url]
|
||||||
@@ -390,11 +382,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
<div class="remote-qr">
|
<div class="remote-qr">
|
||||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
||||||
{(dataUrl) => (
|
{(dataUrl) => (
|
||||||
<img
|
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
||||||
src={dataUrl()}
|
|
||||||
alt={t("remoteAccess.address.qrAlt", { url })}
|
|
||||||
class="remote-qr-img"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
@@ -402,80 +390,7 @@ export const RemoteAccessSettingsSection: Component = () => {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Show>
|
</For>
|
||||||
|
|
||||||
<Show when={displayAddresses().hidden.length > 0}>
|
|
||||||
<div class="remote-address-disclosure" data-expanded={showAllAddresses()}>
|
|
||||||
<button
|
|
||||||
class="remote-address-disclosure-trigger"
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowAllAddresses(!showAllAddresses())}
|
|
||||||
aria-expanded={showAllAddresses()}
|
|
||||||
>
|
|
||||||
<span class="remote-address-disclosure-label">
|
|
||||||
{showAllAddresses()
|
|
||||||
? t("remoteAccess.addresses.actions.hideOther")
|
|
||||||
: t("remoteAccess.addresses.actions.showOther", { count: String(displayAddresses().hidden.length) })}
|
|
||||||
</span>
|
|
||||||
<ChevronDown class={`remote-address-disclosure-chevron ${showAllAddresses() ? "is-expanded" : ""}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<Show when={showAllAddresses()}>
|
|
||||||
<div class="remote-address-disclosure-content">
|
|
||||||
<For each={displayAddresses().hidden}>
|
|
||||||
{(address) => {
|
|
||||||
const url = address.remoteUrl
|
|
||||||
const expandedState = () => expandedUrl() === url
|
|
||||||
const qr = () => qrCodes()[url]
|
|
||||||
const scopeLabel = () =>
|
|
||||||
address.scope === "external"
|
|
||||||
? t("remoteAccess.address.scope.network")
|
|
||||||
: address.scope === "loopback"
|
|
||||||
? t("remoteAccess.address.scope.loopback")
|
|
||||||
: t("remoteAccess.address.scope.internal")
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="remote-address">
|
|
||||||
<div class="remote-address-main">
|
|
||||||
<div>
|
|
||||||
<p class="remote-address-url">{url}</p>
|
|
||||||
<p class="remote-address-meta">
|
|
||||||
{address.family.toUpperCase()} - {scopeLabel()} - {address.ip}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="remote-actions">
|
|
||||||
<button class="remote-pill" type="button" onClick={() => handleOpenUrl(url)}>
|
|
||||||
<ExternalLink class="remote-icon" />
|
|
||||||
{t("remoteAccess.address.open")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="remote-pill"
|
|
||||||
type="button"
|
|
||||||
onClick={() => void toggleExpanded(url)}
|
|
||||||
aria-expanded={expandedState()}
|
|
||||||
>
|
|
||||||
<Link2 class="remote-icon" />
|
|
||||||
{expandedState() ? t("remoteAccess.address.hideQr") : t("remoteAccess.address.showQr")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Show when={expandedState()}>
|
|
||||||
<div class="remote-qr">
|
|
||||||
<Show when={qr()} fallback={<Loader2 class="remote-icon remote-spin" aria-hidden="true" />}>
|
|
||||||
{(dataUrl) => (
|
|
||||||
<img src={dataUrl()} alt={t("remoteAccess.address.qrAlt", { url })} class="remote-qr-img" />
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
"promptInput.voiceInput.transcribing.title": "Transcribing audio",
|
||||||
"promptInput.voiceInput.error.title": "Voice input failed",
|
"promptInput.voiceInput.error.title": "Voice input failed",
|
||||||
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
|
"promptInput.voiceInput.error.permission": "Microphone access is required to record voice input.",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "Microphone access was denied by macOS.",
|
||||||
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
|
"promptInput.voiceInput.error.unsupported": "Voice input is not supported in this browser.",
|
||||||
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
"promptInput.voiceInput.error.transcribe": "Unable to transcribe the recorded audio.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
|
"remoteAccess.sections.addresses.help": "Launch or scan from another machine to hand over control.",
|
||||||
"remoteAccess.addresses.loading": "Loading addresses…",
|
"remoteAccess.addresses.loading": "Loading addresses…",
|
||||||
"remoteAccess.addresses.none": "No addresses available yet.",
|
"remoteAccess.addresses.none": "No addresses available yet.",
|
||||||
"remoteAccess.addresses.actions.showOther": "Show {count} other addresses",
|
|
||||||
"remoteAccess.addresses.actions.hideOther": "Hide other addresses",
|
|
||||||
"remoteAccess.address.scope.network": "Network",
|
"remoteAccess.address.scope.network": "Network",
|
||||||
"remoteAccess.address.scope.loopback": "Loopback",
|
"remoteAccess.address.scope.loopback": "Loopback",
|
||||||
"remoteAccess.address.scope.internal": "Internal",
|
"remoteAccess.address.scope.internal": "Internal",
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "Transcribiendo audio",
|
"promptInput.voiceInput.transcribing.title": "Transcribiendo audio",
|
||||||
"promptInput.voiceInput.error.title": "La entrada de voz falló",
|
"promptInput.voiceInput.error.title": "La entrada de voz falló",
|
||||||
"promptInput.voiceInput.error.permission": "Se requiere acceso al micrófono para grabar la entrada de voz.",
|
"promptInput.voiceInput.error.permission": "Se requiere acceso al micrófono para grabar la entrada de voz.",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "macOS denegó el acceso al micrófono.",
|
||||||
"promptInput.voiceInput.error.unsupported": "La entrada de voz no es compatible con este navegador.",
|
"promptInput.voiceInput.error.unsupported": "La entrada de voz no es compatible con este navegador.",
|
||||||
"promptInput.voiceInput.error.transcribe": "No se pudo transcribir el audio grabado.",
|
"promptInput.voiceInput.error.transcribe": "No se pudo transcribir el audio grabado.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.",
|
"remoteAccess.sections.addresses.help": "Abre o escanea desde otra máquina para transferir el control.",
|
||||||
"remoteAccess.addresses.loading": "Cargando direcciones…",
|
"remoteAccess.addresses.loading": "Cargando direcciones…",
|
||||||
"remoteAccess.addresses.none": "Aún no hay direcciones disponibles.",
|
"remoteAccess.addresses.none": "Aún no hay direcciones disponibles.",
|
||||||
"remoteAccess.addresses.actions.showOther": "Mostrar {count} direcciones más",
|
|
||||||
"remoteAccess.addresses.actions.hideOther": "Ocultar otras direcciones",
|
|
||||||
"remoteAccess.address.scope.network": "Red",
|
"remoteAccess.address.scope.network": "Red",
|
||||||
"remoteAccess.address.scope.loopback": "Loopback",
|
"remoteAccess.address.scope.loopback": "Loopback",
|
||||||
"remoteAccess.address.scope.internal": "Interna",
|
"remoteAccess.address.scope.internal": "Interna",
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "Transcription de l'audio",
|
"promptInput.voiceInput.transcribing.title": "Transcription de l'audio",
|
||||||
"promptInput.voiceInput.error.title": "Échec de la saisie vocale",
|
"promptInput.voiceInput.error.title": "Échec de la saisie vocale",
|
||||||
"promptInput.voiceInput.error.permission": "L'accès au microphone est requis pour enregistrer la saisie vocale.",
|
"promptInput.voiceInput.error.permission": "L'accès au microphone est requis pour enregistrer la saisie vocale.",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "macOS a refusé l'accès au microphone.",
|
||||||
"promptInput.voiceInput.error.unsupported": "La saisie vocale n'est pas prise en charge dans ce navigateur.",
|
"promptInput.voiceInput.error.unsupported": "La saisie vocale n'est pas prise en charge dans ce navigateur.",
|
||||||
"promptInput.voiceInput.error.transcribe": "Impossible de transcrire l'audio enregistré.",
|
"promptInput.voiceInput.error.transcribe": "Impossible de transcrire l'audio enregistré.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.",
|
"remoteAccess.sections.addresses.help": "Lancez ou scannez depuis une autre machine pour passer le contrôle.",
|
||||||
"remoteAccess.addresses.loading": "Chargement des adresses…",
|
"remoteAccess.addresses.loading": "Chargement des adresses…",
|
||||||
"remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.",
|
"remoteAccess.addresses.none": "Aucune adresse disponible pour le moment.",
|
||||||
"remoteAccess.addresses.actions.showOther": "Afficher {count} autres adresses",
|
|
||||||
"remoteAccess.addresses.actions.hideOther": "Masquer les autres adresses",
|
|
||||||
"remoteAccess.address.scope.network": "Réseau",
|
"remoteAccess.address.scope.network": "Réseau",
|
||||||
"remoteAccess.address.scope.loopback": "Boucle locale",
|
"remoteAccess.address.scope.loopback": "Boucle locale",
|
||||||
"remoteAccess.address.scope.internal": "Interne",
|
"remoteAccess.address.scope.internal": "Interne",
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "מתמלל אודיו",
|
"promptInput.voiceInput.transcribing.title": "מתמלל אודיו",
|
||||||
"promptInput.voiceInput.error.title": "קלט קולי נכשל",
|
"promptInput.voiceInput.error.title": "קלט קולי נכשל",
|
||||||
"promptInput.voiceInput.error.permission": "נדרשת גישה למיקרופון כדי להקליט קלט קולי.",
|
"promptInput.voiceInput.error.permission": "נדרשת גישה למיקרופון כדי להקליט קלט קולי.",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "הגישה למיקרופון נדחתה על ידי macOS.",
|
||||||
"promptInput.voiceInput.error.unsupported": "קלט קולי אינו נתמך בדפדפן זה.",
|
"promptInput.voiceInput.error.unsupported": "קלט קולי אינו נתמך בדפדפן זה.",
|
||||||
"promptInput.voiceInput.error.transcribe": "לא ניתן היה לתמלל את האודיו שהוקלט.",
|
"promptInput.voiceInput.error.transcribe": "לא ניתן היה לתמלל את האודיו שהוקלט.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
|
"remoteAccess.sections.addresses.help": "הפעל או סרוק ממכונה אחרת להעברת שליטה.",
|
||||||
"remoteAccess.addresses.loading": "טוען כתובות…",
|
"remoteAccess.addresses.loading": "טוען כתובות…",
|
||||||
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
|
"remoteAccess.addresses.none": "אין כתובות זמינות עדיין.",
|
||||||
"remoteAccess.addresses.actions.showOther": "הצג עוד {count} כתובות",
|
|
||||||
"remoteAccess.addresses.actions.hideOther": "הסתר כתובות נוספות",
|
|
||||||
"remoteAccess.address.scope.network": "רשת",
|
"remoteAccess.address.scope.network": "רשת",
|
||||||
"remoteAccess.address.scope.loopback": "לולאה מקומית",
|
"remoteAccess.address.scope.loopback": "לולאה מקומית",
|
||||||
"remoteAccess.address.scope.internal": "פנימי",
|
"remoteAccess.address.scope.internal": "פנימי",
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "音声を文字起こし中",
|
"promptInput.voiceInput.transcribing.title": "音声を文字起こし中",
|
||||||
"promptInput.voiceInput.error.title": "音声入力に失敗しました",
|
"promptInput.voiceInput.error.title": "音声入力に失敗しました",
|
||||||
"promptInput.voiceInput.error.permission": "音声入力を録音するにはマイクへのアクセスが必要です。",
|
"promptInput.voiceInput.error.permission": "音声入力を録音するにはマイクへのアクセスが必要です。",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "macOS によりマイクへのアクセスが拒否されました。",
|
||||||
"promptInput.voiceInput.error.unsupported": "このブラウザーでは音声入力はサポートされていません。",
|
"promptInput.voiceInput.error.unsupported": "このブラウザーでは音声入力はサポートされていません。",
|
||||||
"promptInput.voiceInput.error.transcribe": "録音した音声を文字起こしできませんでした。",
|
"promptInput.voiceInput.error.transcribe": "録音した音声を文字起こしできませんでした。",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "別の端末から起動またはスキャンして操作を引き継ぎます。",
|
"remoteAccess.sections.addresses.help": "別の端末から起動またはスキャンして操作を引き継ぎます。",
|
||||||
"remoteAccess.addresses.loading": "アドレスを読み込み中…",
|
"remoteAccess.addresses.loading": "アドレスを読み込み中…",
|
||||||
"remoteAccess.addresses.none": "まだ利用可能なアドレスがありません。",
|
"remoteAccess.addresses.none": "まだ利用可能なアドレスがありません。",
|
||||||
"remoteAccess.addresses.actions.showOther": "他の {count} 件のアドレスを表示",
|
|
||||||
"remoteAccess.addresses.actions.hideOther": "他のアドレスを隠す",
|
|
||||||
"remoteAccess.address.scope.network": "ネットワーク",
|
"remoteAccess.address.scope.network": "ネットワーク",
|
||||||
"remoteAccess.address.scope.loopback": "ループバック",
|
"remoteAccess.address.scope.loopback": "ループバック",
|
||||||
"remoteAccess.address.scope.internal": "内部",
|
"remoteAccess.address.scope.internal": "内部",
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "Идёт расшифровка аудио",
|
"promptInput.voiceInput.transcribing.title": "Идёт расшифровка аудио",
|
||||||
"promptInput.voiceInput.error.title": "Сбой голосового ввода",
|
"promptInput.voiceInput.error.title": "Сбой голосового ввода",
|
||||||
"promptInput.voiceInput.error.permission": "Для записи голосового ввода требуется доступ к микрофону.",
|
"promptInput.voiceInput.error.permission": "Для записи голосового ввода требуется доступ к микрофону.",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "macOS запретила доступ к микрофону.",
|
||||||
"promptInput.voiceInput.error.unsupported": "Голосовой ввод не поддерживается в этом браузере.",
|
"promptInput.voiceInput.error.unsupported": "Голосовой ввод не поддерживается в этом браузере.",
|
||||||
"promptInput.voiceInput.error.transcribe": "Не удалось расшифровать записанное аудио.",
|
"promptInput.voiceInput.error.transcribe": "Не удалось расшифровать записанное аудио.",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "Откройте или отсканируйте с другой машины, чтобы передать управление.",
|
"remoteAccess.sections.addresses.help": "Откройте или отсканируйте с другой машины, чтобы передать управление.",
|
||||||
"remoteAccess.addresses.loading": "Загрузка адресов…",
|
"remoteAccess.addresses.loading": "Загрузка адресов…",
|
||||||
"remoteAccess.addresses.none": "Пока нет доступных адресов.",
|
"remoteAccess.addresses.none": "Пока нет доступных адресов.",
|
||||||
"remoteAccess.addresses.actions.showOther": "Показать еще {count} адресов",
|
|
||||||
"remoteAccess.addresses.actions.hideOther": "Скрыть остальные адреса",
|
|
||||||
"remoteAccess.address.scope.network": "Сеть",
|
"remoteAccess.address.scope.network": "Сеть",
|
||||||
"remoteAccess.address.scope.loopback": "Loopback",
|
"remoteAccess.address.scope.loopback": "Loopback",
|
||||||
"remoteAccess.address.scope.internal": "Внутренний",
|
"remoteAccess.address.scope.internal": "Внутренний",
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ export const messagingMessages = {
|
|||||||
"promptInput.voiceInput.transcribing.title": "正在转写音频",
|
"promptInput.voiceInput.transcribing.title": "正在转写音频",
|
||||||
"promptInput.voiceInput.error.title": "语音输入失败",
|
"promptInput.voiceInput.error.title": "语音输入失败",
|
||||||
"promptInput.voiceInput.error.permission": "录制语音输入需要麦克风访问权限。",
|
"promptInput.voiceInput.error.permission": "录制语音输入需要麦克风访问权限。",
|
||||||
|
"promptInput.voiceInput.error.permissionDenied": "macOS 已拒绝麦克风访问。",
|
||||||
"promptInput.voiceInput.error.unsupported": "此浏览器不支持语音输入。",
|
"promptInput.voiceInput.error.unsupported": "此浏览器不支持语音输入。",
|
||||||
"promptInput.voiceInput.error.transcribe": "无法转写录制的音频。",
|
"promptInput.voiceInput.error.transcribe": "无法转写录制的音频。",
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ export const remoteAccessMessages = {
|
|||||||
"remoteAccess.sections.addresses.help": "从另一台设备打开或扫描,以接管控制权。",
|
"remoteAccess.sections.addresses.help": "从另一台设备打开或扫描,以接管控制权。",
|
||||||
"remoteAccess.addresses.loading": "正在加载地址…",
|
"remoteAccess.addresses.loading": "正在加载地址…",
|
||||||
"remoteAccess.addresses.none": "暂时没有可用地址。",
|
"remoteAccess.addresses.none": "暂时没有可用地址。",
|
||||||
"remoteAccess.addresses.actions.showOther": "显示另外 {count} 个地址",
|
|
||||||
"remoteAccess.addresses.actions.hideOther": "隐藏其他地址",
|
|
||||||
"remoteAccess.address.scope.network": "网络",
|
"remoteAccess.address.scope.network": "网络",
|
||||||
"remoteAccess.address.scope.loopback": "回环",
|
"remoteAccess.address.scope.loopback": "回环",
|
||||||
"remoteAccess.address.scope.internal": "内部",
|
"remoteAccess.address.scope.internal": "内部",
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
import assert from "node:assert/strict"
|
|
||||||
import { describe, it } from "node:test"
|
|
||||||
|
|
||||||
import { splitRemoteAddresses } from "./remote-access-addresses"
|
|
||||||
|
|
||||||
describe("splitRemoteAddresses", () => {
|
|
||||||
it("keeps the first remote address visible and collapses the rest", () => {
|
|
||||||
const result = splitRemoteAddresses([
|
|
||||||
{ ip: "127.0.0.1", family: "ipv4", scope: "loopback", remoteUrl: "https://127.0.0.1:9898" },
|
|
||||||
{ ip: "192.168.1.128", family: "ipv4", scope: "external", remoteUrl: "https://192.168.1.128:9898" },
|
|
||||||
{ ip: "172.24.96.1", family: "ipv4", scope: "external", remoteUrl: "https://172.24.96.1:9898" },
|
|
||||||
])
|
|
||||||
|
|
||||||
assert.equal(result.recommended?.ip, "192.168.1.128")
|
|
||||||
assert.deepEqual(result.hidden.map((address) => address.ip), ["172.24.96.1"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import type { NetworkAddress } from "../../../server/src/api-types"
|
|
||||||
|
|
||||||
export interface RemoteAddressGroups {
|
|
||||||
recommended: NetworkAddress | null
|
|
||||||
hidden: NetworkAddress[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function splitRemoteAddresses(addresses: NetworkAddress[]): RemoteAddressGroups {
|
|
||||||
const remoteAddresses = addresses.filter((address) => address.scope !== "loopback")
|
|
||||||
return {
|
|
||||||
recommended: remoteAddresses[0] ?? null,
|
|
||||||
hidden: remoteAddresses.slice(1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -256,55 +256,6 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remote-address-disclosure {
|
|
||||||
border: 1px solid var(--border-base);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--surface-primary);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remote-address-disclosure-trigger {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 40px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr auto 1fr;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-primary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remote-address-disclosure-label {
|
|
||||||
grid-column: 2;
|
|
||||||
justify-self: center;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remote-address-disclosure-chevron {
|
|
||||||
grid-column: 3;
|
|
||||||
justify-self: end;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.remote-address-disclosure-chevron.is-expanded {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remote-address-disclosure-content {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 0 10px 10px;
|
|
||||||
border-top: 1px solid var(--border-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remote-qr {
|
.remote-qr {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
.settings-screen-frame {
|
.settings-screen-frame {
|
||||||
@apply fixed inset-0 z-50 flex items-center justify-center px-4;
|
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
|
||||||
padding-block: 5dvh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Override .modal-surface (defined later in panels.css). */
|
/* Override .modal-surface (defined later in panels.css). */
|
||||||
.modal-surface.settings-screen-shell {
|
.modal-surface.settings-screen-shell {
|
||||||
width: min(1120px, 100%);
|
width: min(1120px, 100%);
|
||||||
height: 100%;
|
height: min(88vh, 920px);
|
||||||
max-height: none;
|
max-height: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
|
||||||
@@ -279,25 +278,10 @@
|
|||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-password-summary-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-password-summary-copy {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-password-actions {
|
.settings-password-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
margin-top: 0;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-form-group {
|
.settings-form-group {
|
||||||
|
|||||||
1
packages/ui/src/types/global.d.ts
vendored
1
packages/ui/src/types/global.d.ts
vendored
@@ -29,6 +29,7 @@ declare global {
|
|||||||
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
openDialog?: (options: ElectronDialogOptions) => Promise<ElectronDialogResult>
|
||||||
getDirectoryPaths?: (paths: string[]) => Promise<string[]>
|
getDirectoryPaths?: (paths: string[]) => Promise<string[]>
|
||||||
getPathForFile?: (file: File) => string | null
|
getPathForFile?: (file: File) => string | null
|
||||||
|
requestMicrophoneAccess?: () => Promise<{ granted: boolean }>
|
||||||
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
setWakeLock?: (enabled: boolean) => Promise<{ enabled: boolean }>
|
||||||
|
|
||||||
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
showNotification?: (payload: { title: string; body: string }) => Promise<{ ok: boolean; reason?: string }>
|
||||||
|
|||||||
Reference in New Issue
Block a user