chore: initialize qiming workspace repository
This commit is contained in:
148
qimingcode/packages/desktop-electron/src/main/apps.ts
Normal file
148
qimingcode/packages/desktop-electron/src/main/apps.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { execFileSync } from "node:child_process"
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
||||
import { dirname, extname, join } from "node:path"
|
||||
|
||||
export function checkAppExists(appName: string): boolean {
|
||||
if (process.platform === "win32") return true
|
||||
if (process.platform === "linux") return true
|
||||
return checkMacosApp(appName)
|
||||
}
|
||||
|
||||
export function resolveAppPath(appName: string): string | null {
|
||||
if (process.platform !== "win32") return appName
|
||||
return resolveWindowsAppPath(appName)
|
||||
}
|
||||
|
||||
export function wslPath(path: string, mode: "windows" | "linux" | null): string {
|
||||
if (process.platform !== "win32") return path
|
||||
|
||||
const flag = mode === "windows" ? "-w" : "-u"
|
||||
try {
|
||||
if (path.startsWith("~")) {
|
||||
const suffix = path.slice(1)
|
||||
const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"`
|
||||
const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd])
|
||||
return output.toString().trim()
|
||||
}
|
||||
|
||||
const output = execFileSync("wsl", ["-e", "wslpath", flag, path])
|
||||
return output.toString().trim()
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
function checkMacosApp(appName: string) {
|
||||
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
|
||||
|
||||
const home = process.env.HOME
|
||||
if (home) locations.push(`${home}/Applications/${appName}.app`)
|
||||
|
||||
if (locations.some((location) => existsSync(location))) return true
|
||||
|
||||
try {
|
||||
execFileSync("which", [appName])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWindowsAppPath(appName: string): string | null {
|
||||
let output: string
|
||||
try {
|
||||
output = execFileSync("where", [appName]).toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const paths = output
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
const hasExt = (path: string, ext: string) => extname(path).toLowerCase() === `.${ext}`
|
||||
|
||||
const exe = paths.find((path) => hasExt(path, "exe"))
|
||||
if (exe) return exe
|
||||
|
||||
const resolveCmd = (path: string) => {
|
||||
const content = readFileSync(path, "utf8")
|
||||
for (const token of content.split('"').map((value: string) => value.trim())) {
|
||||
const lower = token.toLowerCase()
|
||||
if (!lower.includes(".exe")) continue
|
||||
|
||||
const index = lower.indexOf("%~dp0")
|
||||
if (index >= 0) {
|
||||
const base = dirname(path)
|
||||
const suffix = token.slice(index + 5)
|
||||
const resolved = suffix
|
||||
.replace(/\//g, "\\")
|
||||
.split("\\")
|
||||
.filter((part: string) => part && part !== ".")
|
||||
.reduce((current: string, part: string) => {
|
||||
if (part === "..") return dirname(current)
|
||||
return join(current, part)
|
||||
}, base)
|
||||
|
||||
if (existsSync(resolved)) return resolved
|
||||
}
|
||||
|
||||
if (existsSync(token)) return token
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
for (const path of paths) {
|
||||
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
|
||||
const resolved = resolveCmd(path)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
if (!extname(path)) {
|
||||
const cmd = `${path}.cmd`
|
||||
if (existsSync(cmd)) {
|
||||
const resolved = resolveCmd(cmd)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
|
||||
const bat = `${path}.bat`
|
||||
if (existsSync(bat)) {
|
||||
const resolved = resolveCmd(bat)
|
||||
if (resolved) return resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = appName
|
||||
.split("")
|
||||
.filter((value: string) => /[a-z0-9]/i.test(value))
|
||||
.map((value: string) => value.toLowerCase())
|
||||
.join("")
|
||||
|
||||
if (key) {
|
||||
for (const path of paths) {
|
||||
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
|
||||
for (const dir of dirs) {
|
||||
try {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const candidate = join(dir, entry)
|
||||
if (!hasExt(candidate, "exe")) continue
|
||||
const stem = entry.replace(/\.exe$/i, "")
|
||||
const name = stem
|
||||
.split("")
|
||||
.filter((value: string) => /[a-z0-9]/i.test(value))
|
||||
.map((value: string) => value.toLowerCase())
|
||||
.join("")
|
||||
if (name.includes(key) || key.includes(name)) return candidate
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths[0] ?? null
|
||||
}
|
||||
10
qimingcode/packages/desktop-electron/src/main/constants.ts
Normal file
10
qimingcode/packages/desktop-electron/src/main/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { app } from "electron"
|
||||
|
||||
type Channel = "dev" | "beta" | "prod"
|
||||
const raw = import.meta.env.OPENCODE_CHANNEL
|
||||
export const CHANNEL: Channel = raw === "dev" || raw === "beta" || raw === "prod" ? raw : "dev"
|
||||
|
||||
export const SETTINGS_STORE = "opencode.settings"
|
||||
export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
||||
export const WSL_ENABLED_KEY = "wslEnabled"
|
||||
export const UPDATER_ENABLED = app.isPackaged && CHANNEL !== "dev"
|
||||
29
qimingcode/packages/desktop-electron/src/main/env.d.ts
vendored
Normal file
29
qimingcode/packages/desktop-electron/src/main/env.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly OPENCODE_CHANNEL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
declare module "virtual:opencode-server" {
|
||||
export namespace Server {
|
||||
export const listen: typeof import("../../../opencode/dist/types/src/node").Server.listen
|
||||
export type Listener = import("../../../opencode/dist/types/src/node").Server.Listener
|
||||
}
|
||||
export namespace Config {
|
||||
export const get: typeof import("../../../opencode/dist/types/src/node").Config.get
|
||||
export type Info = import("../../../opencode/dist/types/src/node").Config.Info
|
||||
}
|
||||
export namespace Log {
|
||||
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
|
||||
}
|
||||
export namespace Database {
|
||||
export const Path: typeof import("../../../opencode/dist/types/src/node").Database.Path
|
||||
export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
|
||||
}
|
||||
export namespace JsonMigration {
|
||||
export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
|
||||
export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
|
||||
}
|
||||
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
|
||||
}
|
||||
452
qimingcode/packages/desktop-electron/src/main/index.ts
Normal file
452
qimingcode/packages/desktop-electron/src/main/index.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { existsSync } from "node:fs"
|
||||
import { createServer } from "node:net"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { Event } from "electron"
|
||||
import { app, BrowserWindow, dialog } from "electron"
|
||||
import pkg from "electron-updater"
|
||||
|
||||
import contextMenu from "electron-context-menu"
|
||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||
|
||||
// on macOS apps run in `/` which can cause issues with ripgrep
|
||||
try {
|
||||
process.chdir(homedir())
|
||||
} catch {}
|
||||
|
||||
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
beta: "OpenCode Beta",
|
||||
prod: "OpenCode",
|
||||
}
|
||||
const APP_IDS: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev")
|
||||
app.setAppUserModelId(appId)
|
||||
app.setPath("userData", join(app.getPath("appData"), appId))
|
||||
const { autoUpdater } = pkg
|
||||
|
||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
||||
import { initLogging } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
||||
import {
|
||||
createLoadingWindow,
|
||||
createMainWindow,
|
||||
registerRendererProtocol,
|
||||
setBackgroundColor,
|
||||
setDockIcon,
|
||||
} from "./windows"
|
||||
import { drizzle } from "drizzle-orm/node-sqlite/driver"
|
||||
import type { Server } from "virtual:opencode-server"
|
||||
|
||||
const initEmitter = new EventEmitter()
|
||||
let initStep: InitStep = { phase: "server_waiting" }
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let server: Server.Listener | null = null
|
||||
const loadingComplete = defer<void>()
|
||||
|
||||
const pendingDeepLinks: string[] = []
|
||||
|
||||
const serverReady = defer<ServerReadyData>()
|
||||
const logger = initLogging()
|
||||
|
||||
logger.log("app starting", {
|
||||
version: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
})
|
||||
|
||||
setupApp()
|
||||
|
||||
function setupApp() {
|
||||
ensureLoopbackNoProxy()
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
return
|
||||
}
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
if (urls.length) {
|
||||
logger.log("deep link received via second-instance", { urls })
|
||||
emitDeepLinks(urls)
|
||||
}
|
||||
focusMainWindow()
|
||||
})
|
||||
|
||||
app.on("open-url", (event: Event, url: string) => {
|
||||
event.preventDefault()
|
||||
logger.log("deep link received via open-url", { url })
|
||||
emitDeepLinks([url])
|
||||
})
|
||||
|
||||
app.on("before-quit", () => {
|
||||
killSidecar()
|
||||
})
|
||||
|
||||
app.on("will-quit", () => {
|
||||
killSidecar()
|
||||
})
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
killSidecar()
|
||||
app.exit(0)
|
||||
})
|
||||
}
|
||||
|
||||
void app.whenReady().then(async () => {
|
||||
app.setAsDefaultProtocolClient("opencode")
|
||||
registerRendererProtocol()
|
||||
setDockIcon()
|
||||
setupAutoUpdater()
|
||||
await initialize()
|
||||
})
|
||||
}
|
||||
|
||||
function emitDeepLinks(urls: string[]) {
|
||||
if (urls.length === 0) return
|
||||
pendingDeepLinks.push(...urls)
|
||||
if (mainWindow) sendDeepLinks(mainWindow, urls)
|
||||
}
|
||||
|
||||
function focusMainWindow() {
|
||||
if (!mainWindow) return
|
||||
mainWindow.show()
|
||||
mainWindow.focus()
|
||||
}
|
||||
|
||||
function setInitStep(step: InitStep) {
|
||||
initStep = step
|
||||
logger.log("init step", { step })
|
||||
initEmitter.emit("step", step)
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const needsMigration = !sqliteFileExists()
|
||||
const sqliteDone = needsMigration ? defer<void>() : undefined
|
||||
let overlay: BrowserWindow | null = null
|
||||
|
||||
const port = await getSidecarPort()
|
||||
const hostname = "127.0.0.1"
|
||||
const url = `http://${hostname}:${port}`
|
||||
const password = randomUUID()
|
||||
|
||||
const loadingTask = (async () => {
|
||||
logger.log("sidecar connection started", { url })
|
||||
|
||||
initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
|
||||
setInitStep({ phase: "sqlite_waiting" })
|
||||
if (overlay) sendSqliteMigrationProgress(overlay, progress)
|
||||
if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
|
||||
if (progress.type === "Done") sqliteDone?.resolve()
|
||||
})
|
||||
|
||||
if (needsMigration) {
|
||||
const { Database, JsonMigration } = await import("virtual:opencode-server")
|
||||
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
|
||||
progress: (event: { current: number; total: number }) => {
|
||||
const percent = Math.round(event.current / event.total) * 100
|
||||
initEmitter.emit("sqlite", { type: "InProgress", value: percent })
|
||||
},
|
||||
})
|
||||
initEmitter.emit("sqlite", { type: "Done" })
|
||||
|
||||
sqliteDone?.resolve()
|
||||
}
|
||||
|
||||
if (needsMigration) {
|
||||
await sqliteDone?.promise
|
||||
}
|
||||
|
||||
logger.log("spawning sidecar", { url })
|
||||
const { listener, health } = await spawnLocalServer(hostname, port, password)
|
||||
server = listener
|
||||
serverReady.resolve({
|
||||
url,
|
||||
username: "opencode",
|
||||
password,
|
||||
})
|
||||
|
||||
await Promise.race([
|
||||
health.wait,
|
||||
delay(30_000).then(() => {
|
||||
throw new Error("Sidecar health check timed out")
|
||||
}),
|
||||
]).catch((error) => {
|
||||
logger.error("sidecar health check failed", error)
|
||||
})
|
||||
|
||||
logger.log("loading task finished")
|
||||
})()
|
||||
|
||||
if (needsMigration) {
|
||||
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
|
||||
if (show) {
|
||||
overlay = createLoadingWindow()
|
||||
await delay(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
await loadingTask
|
||||
setInitStep({ phase: "done" })
|
||||
|
||||
if (overlay) {
|
||||
await loadingComplete.promise
|
||||
}
|
||||
|
||||
mainWindow = createMainWindow()
|
||||
wireMenu()
|
||||
|
||||
overlay?.close()
|
||||
}
|
||||
|
||||
function wireMenu() {
|
||||
if (!mainWindow) return
|
||||
createMenu({
|
||||
trigger: (id) => mainWindow && sendMenuCommand(mainWindow, id),
|
||||
checkForUpdates: () => {
|
||||
void checkForUpdates(true)
|
||||
},
|
||||
reload: () => mainWindow?.reload(),
|
||||
relaunch: () => {
|
||||
killSidecar()
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
registerIpcHandlers({
|
||||
killSidecar: () => killSidecar(),
|
||||
awaitInitialization: async (sendStep) => {
|
||||
sendStep(initStep)
|
||||
const listener = (step: InitStep) => sendStep(step)
|
||||
initEmitter.on("step", listener)
|
||||
try {
|
||||
logger.log("awaiting server ready")
|
||||
const res = await serverReady.promise
|
||||
logger.log("server ready", { url: res.url })
|
||||
return res
|
||||
} finally {
|
||||
initEmitter.off("step", listener)
|
||||
}
|
||||
},
|
||||
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
||||
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
|
||||
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||
getWslConfig: () => Promise.resolve(getWslConfig()),
|
||||
setWslConfig: (config: WslConfig) => setWslConfig(config),
|
||||
getDisplayBackend: async () => null,
|
||||
setDisplayBackend: async () => undefined,
|
||||
parseMarkdown: async (markdown) => parseMarkdown(markdown),
|
||||
checkAppExists: async (appName) => checkAppExists(appName),
|
||||
wslPath: async (path, mode) => wslPath(path, mode),
|
||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||
loadingWindowComplete: () => loadingComplete.resolve(),
|
||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail),
|
||||
checkUpdate: async () => checkUpdate(),
|
||||
installUpdate: async () => installUpdate(),
|
||||
setBackgroundColor: (color) => setBackgroundColor(color),
|
||||
})
|
||||
|
||||
function killSidecar() {
|
||||
if (!server) return
|
||||
server.stop()
|
||||
server = null
|
||||
}
|
||||
|
||||
function ensureLoopbackNoProxy() {
|
||||
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||
const upsert = (key: string) => {
|
||||
const items = (process.env[key] ?? "")
|
||||
.split(",")
|
||||
.map((value: string) => value.trim())
|
||||
.filter((value: string) => Boolean(value))
|
||||
|
||||
for (const host of loopback) {
|
||||
if (items.some((value: string) => value.toLowerCase() === host)) continue
|
||||
items.push(host)
|
||||
}
|
||||
|
||||
process.env[key] = items.join(",")
|
||||
}
|
||||
|
||||
upsert("NO_PROXY")
|
||||
upsert("no_proxy")
|
||||
}
|
||||
|
||||
async function getSidecarPort() {
|
||||
const fromEnv = process.env.OPENCODE_PORT
|
||||
if (fromEnv) {
|
||||
const parsed = Number.parseInt(fromEnv, 10)
|
||||
if (!Number.isNaN(parsed)) return parsed
|
||||
}
|
||||
|
||||
return await new Promise<number>((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.on("error", reject)
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (typeof address !== "object" || !address) {
|
||||
server.close()
|
||||
reject(new Error("Failed to get port"))
|
||||
return
|
||||
}
|
||||
const port = address.port
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function sqliteFileExists() {
|
||||
const xdg = process.env.XDG_DATA_HOME
|
||||
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
|
||||
return existsSync(join(base, "opencode", "opencode.db"))
|
||||
}
|
||||
|
||||
function setupAutoUpdater() {
|
||||
if (!UPDATER_ENABLED) return
|
||||
autoUpdater.logger = logger
|
||||
autoUpdater.channel = "latest"
|
||||
autoUpdater.allowPrerelease = false
|
||||
autoUpdater.allowDowngrade = true
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
logger.log("auto updater configured", {
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
currentVersion: app.getVersion(),
|
||||
})
|
||||
}
|
||||
|
||||
let downloadedUpdateVersion: string | undefined
|
||||
|
||||
async function checkUpdate() {
|
||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
||||
if (downloadedUpdateVersion) {
|
||||
logger.log("returning cached downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
})
|
||||
return { updateAvailable: true, version: downloadedUpdateVersion }
|
||||
}
|
||||
logger.log("checking for updates", {
|
||||
currentVersion: app.getVersion(),
|
||||
channel: autoUpdater.channel,
|
||||
allowPrerelease: autoUpdater.allowPrerelease,
|
||||
allowDowngrade: autoUpdater.allowDowngrade,
|
||||
})
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates()
|
||||
const updateInfo = result?.updateInfo
|
||||
logger.log("update metadata fetched", {
|
||||
releaseVersion: updateInfo?.version ?? null,
|
||||
releaseDate: updateInfo?.releaseDate ?? null,
|
||||
releaseName: updateInfo?.releaseName ?? null,
|
||||
files: updateInfo?.files?.map((file) => file.url) ?? [],
|
||||
})
|
||||
const version = result?.updateInfo?.version
|
||||
if (result?.isUpdateAvailable === false || !version) {
|
||||
logger.log("no update available", {
|
||||
reason: "provider returned no newer version",
|
||||
})
|
||||
return { updateAvailable: false }
|
||||
}
|
||||
logger.log("update available", { version })
|
||||
await autoUpdater.downloadUpdate()
|
||||
logger.log("update download completed", { version })
|
||||
downloadedUpdateVersion = version
|
||||
return { updateAvailable: true, version }
|
||||
} catch (error) {
|
||||
logger.error("update check failed", error)
|
||||
return { updateAvailable: false, failed: true }
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpdate() {
|
||||
if (!downloadedUpdateVersion) {
|
||||
logger.log("install update skipped", {
|
||||
reason: "no downloaded update ready",
|
||||
})
|
||||
return
|
||||
}
|
||||
logger.log("installing downloaded update", {
|
||||
version: downloadedUpdateVersion,
|
||||
})
|
||||
killSidecar()
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
|
||||
async function checkForUpdates(alertOnFail: boolean) {
|
||||
if (!UPDATER_ENABLED) return
|
||||
logger.log("checkForUpdates invoked", { alertOnFail })
|
||||
const result = await checkUpdate()
|
||||
if (!result.updateAvailable) {
|
||||
if (result.failed) {
|
||||
logger.log("no update decision", { reason: "update check failed" })
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "error",
|
||||
message: "Update check failed.",
|
||||
title: "Update Error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
logger.log("no update decision", { reason: "already up to date" })
|
||||
if (!alertOnFail) return
|
||||
await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: "You're up to date.",
|
||||
title: "No Updates",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const response = await dialog.showMessageBox({
|
||||
type: "info",
|
||||
message: `Update ${result.version ?? ""} downloaded. Restart now?`,
|
||||
title: "Update Ready",
|
||||
buttons: ["Restart", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
})
|
||||
logger.log("update prompt response", {
|
||||
version: result.version ?? null,
|
||||
restartNow: response.response === 0,
|
||||
})
|
||||
if (response.response === 0) {
|
||||
await installUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
204
qimingcode/packages/desktop-electron/src/main/ipc.ts
Normal file
204
qimingcode/packages/desktop-electron/src/main/ipc.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { execFile } from "node:child_process"
|
||||
import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron"
|
||||
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
||||
|
||||
import type {
|
||||
InitStep,
|
||||
ServerReadyData,
|
||||
SqliteMigrationProgress,
|
||||
TitlebarTheme,
|
||||
WindowConfig,
|
||||
WslConfig,
|
||||
} from "../preload/types"
|
||||
import { getStore } from "./store"
|
||||
import { setTitlebar } from "./windows"
|
||||
|
||||
const pickerFilters = (ext?: string[]) => {
|
||||
if (!ext || ext.length === 0) return undefined
|
||||
return [{ name: "Files", extensions: ext }]
|
||||
}
|
||||
|
||||
type Deps = {
|
||||
killSidecar: () => void
|
||||
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void> | void
|
||||
getWslConfig: () => Promise<WslConfig>
|
||||
setWslConfig: (config: WslConfig) => Promise<void> | void
|
||||
getDisplayBackend: () => Promise<string | null>
|
||||
setDisplayBackend: (backend: string | null) => Promise<void> | void
|
||||
parseMarkdown: (markdown: string) => Promise<string> | string
|
||||
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
||||
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
|
||||
resolveAppPath: (appName: string) => Promise<string | null>
|
||||
loadingWindowComplete: () => void
|
||||
runUpdater: (alertOnFail: boolean) => Promise<void> | void
|
||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||
installUpdate: () => Promise<void> | void
|
||||
setBackgroundColor: (color: string) => void
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
|
||||
const send = (step: InitStep) => event.sender.send("init-step", step)
|
||||
return deps.awaitInitialization(send)
|
||||
})
|
||||
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
|
||||
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
||||
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
||||
ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) =>
|
||||
deps.setDefaultServerUrl(url),
|
||||
)
|
||||
ipcMain.handle("get-wsl-config", () => deps.getWslConfig())
|
||||
ipcMain.handle("set-wsl-config", (_event: IpcMainInvokeEvent, config: WslConfig) => deps.setWslConfig(config))
|
||||
ipcMain.handle("get-display-backend", () => deps.getDisplayBackend())
|
||||
ipcMain.handle("set-display-backend", (_event: IpcMainInvokeEvent, backend: string | null) =>
|
||||
deps.setDisplayBackend(backend),
|
||||
)
|
||||
ipcMain.handle("parse-markdown", (_event: IpcMainInvokeEvent, markdown: string) => deps.parseMarkdown(markdown))
|
||||
ipcMain.handle("check-app-exists", (_event: IpcMainInvokeEvent, appName: string) => deps.checkAppExists(appName))
|
||||
ipcMain.handle("wsl-path", (_event: IpcMainInvokeEvent, path: string, mode: "windows" | "linux" | null) =>
|
||||
deps.wslPath(path, mode),
|
||||
)
|
||||
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
|
||||
ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
|
||||
ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
|
||||
ipcMain.handle("check-update", () => deps.checkUpdate())
|
||||
ipcMain.handle("install-update", () => deps.installUpdate())
|
||||
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
|
||||
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
const store = getStore(name)
|
||||
const value = store.get(key)
|
||||
if (value === undefined || value === null) return null
|
||||
return typeof value === "string" ? value : JSON.stringify(value)
|
||||
})
|
||||
ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
|
||||
getStore(name).set(key, value)
|
||||
})
|
||||
ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
getStore(name).delete(key)
|
||||
})
|
||||
ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
getStore(name).clear()
|
||||
})
|
||||
ipcMain.handle("store-keys", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
const store = getStore(name)
|
||||
return Object.keys(store.store)
|
||||
})
|
||||
ipcMain.handle("store-length", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
const store = getStore(name)
|
||||
return Object.keys(store.store).length
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
"open-directory-picker",
|
||||
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||
title: opts?.title ?? "Choose a folder",
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"open-file-picker",
|
||||
async (
|
||||
_event: IpcMainInvokeEvent,
|
||||
opts?: { multiple?: boolean; title?: string; defaultPath?: string; accept?: string[]; extensions?: string[] },
|
||||
) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||
title: opts?.title ?? "Choose a file",
|
||||
defaultPath: opts?.defaultPath,
|
||||
filters: pickerFilters(opts?.extensions),
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return opts?.multiple ? result.filePaths : result.filePaths[0]
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
"save-file-picker",
|
||||
async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => {
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: opts?.title ?? "Save file",
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
if (result.canceled) return null
|
||||
return result.filePath ?? null
|
||||
},
|
||||
)
|
||||
|
||||
ipcMain.on("open-link", (_event: IpcMainEvent, url: string) => {
|
||||
void shell.openExternal(url)
|
||||
})
|
||||
|
||||
ipcMain.handle("open-path", async (_event: IpcMainInvokeEvent, path: string, app?: string) => {
|
||||
if (!app) return shell.openPath(path)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const [cmd, args] =
|
||||
process.platform === "darwin" ? (["open", ["-a", app, path]] as const) : ([app, [path]] as const)
|
||||
execFile(cmd, args, (err) => (err ? reject(err) : resolve()))
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.handle("read-clipboard-image", () => {
|
||||
const image = clipboard.readImage()
|
||||
if (image.isEmpty()) return null
|
||||
const buffer = image.toPNG().buffer
|
||||
const size = image.getSize()
|
||||
return { buffer, width: size.width, height: size.height }
|
||||
})
|
||||
|
||||
ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
|
||||
new Notification({ title, body }).show()
|
||||
})
|
||||
|
||||
ipcMain.handle("get-window-count", () => BrowserWindow.getAllWindows().length)
|
||||
|
||||
ipcMain.handle("get-window-focused", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
return win?.isFocused() ?? false
|
||||
})
|
||||
|
||||
ipcMain.handle("set-window-focus", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.focus()
|
||||
})
|
||||
|
||||
ipcMain.handle("show-window", (event: IpcMainInvokeEvent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
win?.show()
|
||||
})
|
||||
|
||||
ipcMain.on("relaunch", () => {
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
})
|
||||
|
||||
ipcMain.handle("get-zoom-factor", (event: IpcMainInvokeEvent) => event.sender.getZoomFactor())
|
||||
ipcMain.handle("set-zoom-factor", (event: IpcMainInvokeEvent, factor: number) => event.sender.setZoomFactor(factor))
|
||||
ipcMain.handle("set-titlebar", (event: IpcMainInvokeEvent, theme: TitlebarTheme) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!win) return
|
||||
setTitlebar(win, theme)
|
||||
})
|
||||
}
|
||||
|
||||
export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
|
||||
win.webContents.send("sqlite-migration-progress", progress)
|
||||
}
|
||||
|
||||
export function sendMenuCommand(win: BrowserWindow, id: string) {
|
||||
win.webContents.send("menu-command", id)
|
||||
}
|
||||
|
||||
export function sendDeepLinks(win: BrowserWindow, urls: string[]) {
|
||||
win.webContents.send("deep-link", urls)
|
||||
}
|
||||
40
qimingcode/packages/desktop-electron/src/main/logging.ts
Normal file
40
qimingcode/packages/desktop-electron/src/main/logging.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import log from "electron-log/main.js"
|
||||
import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
|
||||
const MAX_LOG_AGE_DAYS = 7
|
||||
const TAIL_LINES = 1000
|
||||
|
||||
export function initLogging() {
|
||||
log.transports.file.maxSize = 5 * 1024 * 1024
|
||||
cleanup()
|
||||
return log
|
||||
}
|
||||
|
||||
export function tail(): string {
|
||||
try {
|
||||
const path = log.transports.file.getFile().path
|
||||
const contents = readFileSync(path, "utf8")
|
||||
const lines = contents.split("\n")
|
||||
return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n")
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
const path = log.transports.file.getFile().path
|
||||
const dir = dirname(path)
|
||||
const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000
|
||||
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const file = join(dir, entry)
|
||||
try {
|
||||
const info = statSync(file)
|
||||
if (!info.isFile()) continue
|
||||
if (info.mtimeMs < cutoff) unlinkSync(file)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
16
qimingcode/packages/desktop-electron/src/main/markdown.ts
Normal file
16
qimingcode/packages/desktop-electron/src/main/markdown.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { marked, type Tokens } from "marked"
|
||||
|
||||
const renderer = new marked.Renderer()
|
||||
|
||||
renderer.link = ({ href, title, text }: Tokens.Link) => {
|
||||
const titleAttr = title ? ` title="${title}"` : ""
|
||||
return `<a href="${href}"${titleAttr} class="external-link" target="_blank" rel="noopener noreferrer">${text}</a>`
|
||||
}
|
||||
|
||||
export function parseMarkdown(input: string) {
|
||||
return marked(input, {
|
||||
renderer,
|
||||
breaks: false,
|
||||
gfm: true,
|
||||
})
|
||||
}
|
||||
136
qimingcode/packages/desktop-electron/src/main/menu.ts
Normal file
136
qimingcode/packages/desktop-electron/src/main/menu.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Menu, shell } from "electron"
|
||||
|
||||
import { UPDATER_ENABLED } from "./constants"
|
||||
import { createMainWindow } from "./windows"
|
||||
|
||||
type Deps = {
|
||||
trigger: (id: string) => void
|
||||
checkForUpdates: () => void
|
||||
reload: () => void
|
||||
relaunch: () => void
|
||||
}
|
||||
|
||||
export function createMenu(deps: Deps) {
|
||||
if (process.platform !== "darwin") return
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: "OpenCode",
|
||||
submenu: [
|
||||
{ role: "about" },
|
||||
{
|
||||
label: "Check for Updates...",
|
||||
enabled: UPDATER_ENABLED,
|
||||
click: () => deps.checkForUpdates(),
|
||||
},
|
||||
{
|
||||
label: "Reload Webview",
|
||||
click: () => deps.reload(),
|
||||
},
|
||||
{
|
||||
label: "Restart",
|
||||
click: () => deps.relaunch(),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "unhide" },
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "File",
|
||||
submenu: [
|
||||
{ label: "New Session", accelerator: "Shift+Cmd+S", click: () => deps.trigger("session.new") },
|
||||
{ label: "Open Project...", accelerator: "Cmd+O", click: () => deps.trigger("project.open") },
|
||||
{
|
||||
label: "New Window",
|
||||
accelerator: "Cmd+Shift+N",
|
||||
click: () => createMainWindow(),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "close" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ label: "Toggle Sidebar", accelerator: "Cmd+B", click: () => deps.trigger("sidebar.toggle") },
|
||||
{ label: "Toggle Terminal", accelerator: "Ctrl+`", click: () => deps.trigger("terminal.toggle") },
|
||||
{ label: "Toggle File Tree", click: () => deps.trigger("fileTree.toggle") },
|
||||
{ type: "separator" },
|
||||
{ role: "reload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Go",
|
||||
submenu: [
|
||||
{ label: "Back", accelerator: "Cmd+[", click: () => deps.trigger("common.goBack") },
|
||||
{ label: "Forward", accelerator: "Cmd+]", click: () => deps.trigger("common.goForward") },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Previous Session",
|
||||
accelerator: "Option+Up",
|
||||
click: () => deps.trigger("session.previous"),
|
||||
},
|
||||
{
|
||||
label: "Next Session",
|
||||
accelerator: "Option+Down",
|
||||
click: () => deps.trigger("session.next"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Previous Project",
|
||||
accelerator: "Cmd+Option+Up",
|
||||
click: () => deps.trigger("project.previous"),
|
||||
},
|
||||
{
|
||||
label: "Next Project",
|
||||
accelerator: "Cmd+Option+Down",
|
||||
click: () => deps.trigger("project.next"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "windowMenu" },
|
||||
{
|
||||
label: "Help",
|
||||
submenu: [
|
||||
{ label: "OpenCode Documentation", click: () => shell.openExternal("https://opencode.ai/docs") },
|
||||
{ label: "Support Forum", click: () => shell.openExternal("https://discord.com/invite/opencode") },
|
||||
{ type: "separator" },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Share Feedback",
|
||||
click: () =>
|
||||
shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml"),
|
||||
},
|
||||
{
|
||||
label: "Report a Bug",
|
||||
click: () => shell.openExternal("https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml"),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
91
qimingcode/packages/desktop-electron/src/main/migrate.ts
Normal file
91
qimingcode/packages/desktop-electron/src/main/migrate.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { app } from "electron"
|
||||
import log from "electron-log/main.js"
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { CHANNEL } from "./constants"
|
||||
import { getStore } from "./store"
|
||||
|
||||
const TAURI_MIGRATED_KEY = "tauriMigrated"
|
||||
|
||||
// Resolve the directory where Tauri stored its .dat files for the given app identifier.
|
||||
// Mirrors Tauri's AppLocalData / AppData resolution per OS.
|
||||
function tauriDir(id: string) {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return join(homedir(), "Library", "Application Support", id)
|
||||
case "win32":
|
||||
return join(process.env.APPDATA ?? join(homedir(), "AppData", "Roaming"), id)
|
||||
default:
|
||||
return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), id)
|
||||
}
|
||||
}
|
||||
|
||||
// The Tauri app identifier changes between dev/beta/prod builds.
|
||||
const TAURI_APP_IDS: Record<string, string> = {
|
||||
dev: "ai.opencode.desktop.dev",
|
||||
beta: "ai.opencode.desktop.beta",
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
function tauriAppId() {
|
||||
return app.isPackaged ? TAURI_APP_IDS[CHANNEL] : "ai.opencode.desktop.dev"
|
||||
}
|
||||
|
||||
// Migrate a single Tauri .dat file into the corresponding electron-store.
|
||||
// `opencode.settings.dat` is special: it maps to the `opencode.settings` store
|
||||
// (the electron-store name without the `.dat` extension). All other .dat files
|
||||
// keep their full filename as the electron-store name so they match what the
|
||||
// renderer already passes via IPC (e.g. `"default.dat"`, `"opencode.global.dat"`).
|
||||
function migrateFile(datPath: string, filename: string) {
|
||||
let data: Record<string, unknown>
|
||||
try {
|
||||
data = JSON.parse(readFileSync(datPath, "utf-8"))
|
||||
} catch (err) {
|
||||
log.warn("tauri migration: failed to parse", filename, err)
|
||||
return
|
||||
}
|
||||
|
||||
// opencode.settings.dat → the electron settings store ("opencode.settings").
|
||||
// All other .dat files keep their full filename as the store name so they match
|
||||
// what the renderer passes via IPC (e.g. "default.dat", "opencode.global.dat").
|
||||
const storeName = filename === "opencode.settings.dat" ? "opencode.settings" : filename
|
||||
const target = getStore(storeName)
|
||||
const migrated: string[] = []
|
||||
const skipped: string[] = []
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Don't overwrite values the user has already set in the Electron app.
|
||||
if (target.has(key)) {
|
||||
skipped.push(key)
|
||||
continue
|
||||
}
|
||||
target.set(key, value)
|
||||
migrated.push(key)
|
||||
}
|
||||
|
||||
log.log("tauri migration: migrated", filename, "→", storeName, { migrated, skipped })
|
||||
}
|
||||
|
||||
export function migrate() {
|
||||
if (getStore().get(TAURI_MIGRATED_KEY)) {
|
||||
log.log("tauri migration: already done, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
const dir = tauriDir(tauriAppId())
|
||||
log.log("tauri migration: starting", { dir })
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
log.log("tauri migration: no tauri data directory found, nothing to migrate")
|
||||
getStore().set(TAURI_MIGRATED_KEY, true)
|
||||
return
|
||||
}
|
||||
|
||||
for (const filename of readdirSync(dir)) {
|
||||
if (!filename.endsWith(".dat")) continue
|
||||
migrateFile(join(dir, filename), filename)
|
||||
}
|
||||
|
||||
log.log("tauri migration: complete")
|
||||
getStore().set(TAURI_MIGRATED_KEY, true)
|
||||
}
|
||||
101
qimingcode/packages/desktop-electron/src/main/server.ts
Normal file
101
qimingcode/packages/desktop-electron/src/main/server.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { app } from "electron"
|
||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||
import { getStore } from "./store"
|
||||
|
||||
export type WslConfig = { enabled: boolean }
|
||||
|
||||
export type HealthCheck = { wait: Promise<void> }
|
||||
|
||||
export function getDefaultServerUrl(): string | null {
|
||||
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
||||
return typeof value === "string" ? value : null
|
||||
}
|
||||
|
||||
export function setDefaultServerUrl(url: string | null) {
|
||||
if (url) {
|
||||
getStore().set(DEFAULT_SERVER_URL_KEY, url)
|
||||
return
|
||||
}
|
||||
|
||||
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
||||
}
|
||||
|
||||
export function getWslConfig(): WslConfig {
|
||||
const value = getStore().get(WSL_ENABLED_KEY)
|
||||
return { enabled: typeof value === "boolean" ? value : false }
|
||||
}
|
||||
|
||||
export function setWslConfig(config: WslConfig) {
|
||||
getStore().set(WSL_ENABLED_KEY, config.enabled)
|
||||
}
|
||||
|
||||
export async function spawnLocalServer(hostname: string, port: number, password: string) {
|
||||
prepareServerEnv(password)
|
||||
const { Log, Server } = await import("virtual:opencode-server")
|
||||
await Log.init({ level: "WARN" })
|
||||
const listener = await Server.listen({
|
||||
port,
|
||||
hostname,
|
||||
username: "opencode",
|
||||
password,
|
||||
cors: ["oc://renderer"],
|
||||
})
|
||||
|
||||
const wait = (async () => {
|
||||
const url = `http://${hostname}:${port}`
|
||||
|
||||
const ready = async () => {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
if (await checkHealth(url, password)) return
|
||||
}
|
||||
}
|
||||
|
||||
await ready()
|
||||
})()
|
||||
|
||||
return { listener, health: { wait } }
|
||||
}
|
||||
|
||||
function prepareServerEnv(password: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
|
||||
const env = {
|
||||
...process.env,
|
||||
...shellEnv,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
OPENCODE_SERVER_USERNAME: "opencode",
|
||||
OPENCODE_SERVER_PASSWORD: password,
|
||||
XDG_STATE_HOME: app.getPath("userData"),
|
||||
}
|
||||
Object.assign(process.env, env)
|
||||
}
|
||||
|
||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
||||
let healthUrl: URL
|
||||
try {
|
||||
healthUrl = new URL("/global/health", url)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
if (password) {
|
||||
const auth = Buffer.from(`opencode:${password}`).toString("base64")
|
||||
headers.set("authorization", `Basic ${auth}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(healthUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env"
|
||||
|
||||
describe("shell env", () => {
|
||||
test("parseShellEnv supports null-delimited pairs", () => {
|
||||
const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin:/bin")
|
||||
expect(env.FOO).toBe("bar=baz")
|
||||
})
|
||||
|
||||
test("parseShellEnv ignores invalid entries", () => {
|
||||
const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
|
||||
|
||||
expect(Object.keys(env).length).toBe(1)
|
||||
expect(env.OK).toBe("1")
|
||||
})
|
||||
|
||||
test("mergeShellEnv keeps explicit overrides", () => {
|
||||
const env = mergeShellEnv(
|
||||
{
|
||||
PATH: "/shell/path",
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
{
|
||||
PATH: "/desktop/path",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
},
|
||||
)
|
||||
|
||||
expect(env.PATH).toBe("/desktop/path")
|
||||
expect(env.HOME).toBe("/tmp/home")
|
||||
expect(env.OPENCODE_CLIENT).toBe("desktop")
|
||||
})
|
||||
|
||||
test("isNushell handles path and binary name", () => {
|
||||
expect(isNushell("nu")).toBe(true)
|
||||
expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
|
||||
expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
|
||||
expect(isNushell("/bin/zsh")).toBe(false)
|
||||
})
|
||||
})
|
||||
88
qimingcode/packages/desktop-electron/src/main/shell-env.ts
Normal file
88
qimingcode/packages/desktop-electron/src/main/shell-env.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { basename } from "node:path"
|
||||
|
||||
const TIMEOUT = 5_000
|
||||
|
||||
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
|
||||
|
||||
export function getUserShell() {
|
||||
return process.env.SHELL || "/bin/sh"
|
||||
}
|
||||
|
||||
export function parseShellEnv(out: Buffer) {
|
||||
const env: Record<string, string> = {}
|
||||
for (const line of out.toString("utf8").split("\0")) {
|
||||
if (!line) continue
|
||||
const ix = line.indexOf("=")
|
||||
if (ix <= 0) continue
|
||||
env[line.slice(0, ix)] = line.slice(ix + 1)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function probe(shell: string, mode: "-il" | "-l"): Probe {
|
||||
const out = spawnSync(shell, [mode, "-c", "env -0"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: TIMEOUT,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
const err = out.error as NodeJS.ErrnoException | undefined
|
||||
if (err) {
|
||||
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
|
||||
console.log(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
if (out.status !== 0) {
|
||||
console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
const env = parseShellEnv(out.stdout)
|
||||
if (Object.keys(env).length === 0) {
|
||||
console.log(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
return { type: "Loaded", value: env }
|
||||
}
|
||||
|
||||
export function isNushell(shell: string) {
|
||||
const name = basename(shell).toLowerCase()
|
||||
const raw = shell.toLowerCase()
|
||||
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
|
||||
}
|
||||
|
||||
export function loadShellEnv(shell: string) {
|
||||
if (isNushell(shell)) {
|
||||
console.log(`[server] Skipping shell env probe for nushell: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const interactive = probe(shell, "-il")
|
||||
if (interactive.type === "Loaded") {
|
||||
console.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
return interactive.value
|
||||
}
|
||||
if (interactive.type === "Timeout") {
|
||||
console.warn(`[server] Interactive shell env probe timed out: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const login = probe(shell, "-l")
|
||||
if (login.type === "Loaded") {
|
||||
console.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
return login.value
|
||||
}
|
||||
|
||||
console.warn(`[server] Falling back to app environment: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
|
||||
return {
|
||||
...shell,
|
||||
...env,
|
||||
}
|
||||
}
|
||||
17
qimingcode/packages/desktop-electron/src/main/store.ts
Normal file
17
qimingcode/packages/desktop-electron/src/main/store.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import Store from "electron-store"
|
||||
|
||||
import { SETTINGS_STORE } from "./constants"
|
||||
|
||||
const cache = new Map<string, Store>()
|
||||
|
||||
// We cannot instantiate the electron-store at module load time because
|
||||
// module import hoisting causes this to run before app.setPath("userData", ...)
|
||||
// in index.ts has executed, which would result in files being written to the default directory
|
||||
// (e.g. bad: %APPDATA%\@opencode-ai\desktop-electron\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
|
||||
export function getStore(name = SETTINGS_STORE) {
|
||||
const cached = cache.get(name)
|
||||
if (cached) return cached
|
||||
const next = new Store({ name, fileExtension: "", accessPropertiesByDotNotation: false })
|
||||
cache.set(name, next)
|
||||
return next
|
||||
}
|
||||
206
qimingcode/packages/desktop-electron/src/main/windows.ts
Normal file
206
qimingcode/packages/desktop-electron/src/main/windows.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import windowState from "electron-window-state"
|
||||
import { app, BrowserWindow, net, nativeImage, nativeTheme, protocol } from "electron"
|
||||
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
|
||||
import { fileURLToPath, pathToFileURL } from "node:url"
|
||||
import type { TitlebarTheme } from "../preload/types"
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url))
|
||||
const rendererRoot = join(root, "../renderer")
|
||||
const rendererProtocol = "oc"
|
||||
const rendererHost = "renderer"
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: rendererProtocol,
|
||||
privileges: {
|
||||
secure: true,
|
||||
standard: true,
|
||||
supportFetchAPI: true,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
let backgroundColor: string | undefined
|
||||
|
||||
export function setBackgroundColor(color: string) {
|
||||
backgroundColor = color
|
||||
}
|
||||
|
||||
export function getBackgroundColor(): string | undefined {
|
||||
return backgroundColor
|
||||
}
|
||||
|
||||
function iconsDir() {
|
||||
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
|
||||
}
|
||||
|
||||
function iconPath() {
|
||||
const ext = process.platform === "win32" ? "ico" : "png"
|
||||
return join(iconsDir(), `icon.${ext}`)
|
||||
}
|
||||
|
||||
function tone() {
|
||||
return nativeTheme.shouldUseDarkColors ? "dark" : "light"
|
||||
}
|
||||
|
||||
function overlay(theme: Partial<TitlebarTheme> = {}) {
|
||||
const mode = theme.mode ?? tone()
|
||||
return {
|
||||
color: "#00000000",
|
||||
symbolColor: mode === "dark" ? "white" : "black",
|
||||
height: 40,
|
||||
}
|
||||
}
|
||||
|
||||
export function setTitlebar(win: BrowserWindow, theme: Partial<TitlebarTheme> = {}) {
|
||||
if (process.platform !== "win32") return
|
||||
win.setTitleBarOverlay(overlay(theme))
|
||||
}
|
||||
|
||||
export function setDockIcon() {
|
||||
if (process.platform !== "darwin") return
|
||||
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
|
||||
if (!icon.isEmpty()) app.dock?.setIcon(icon)
|
||||
}
|
||||
|
||||
export function createMainWindow() {
|
||||
const state = windowState({
|
||||
defaultWidth: 1280,
|
||||
defaultHeight: 800,
|
||||
})
|
||||
|
||||
const mode = tone()
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
show: false,
|
||||
title: "OpenCode",
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
...(process.platform === "darwin"
|
||||
? {
|
||||
titleBarStyle: "hidden" as const,
|
||||
trafficLightPosition: { x: 12, y: 14 },
|
||||
}
|
||||
: {}),
|
||||
...(process.platform === "win32"
|
||||
? {
|
||||
frame: false,
|
||||
titleBarStyle: "hidden" as const,
|
||||
titleBarOverlay: overlay({ mode }),
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
preload: join(root, "../preload/index.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
|
||||
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
|
||||
const { requestHeaders } = details
|
||||
upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"])
|
||||
callback({ requestHeaders })
|
||||
})
|
||||
|
||||
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||
const { responseHeaders = {} } = details
|
||||
upsertKeyValue(responseHeaders, "Access-Control-Allow-Origin", ["*"])
|
||||
upsertKeyValue(responseHeaders, "Access-Control-Allow-Headers", ["*"])
|
||||
callback({ responseHeaders })
|
||||
})
|
||||
|
||||
state.manage(win)
|
||||
loadWindow(win, "index.html")
|
||||
wireZoom(win)
|
||||
|
||||
win.once("ready-to-show", () => {
|
||||
win.show()
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
export function createLoadingWindow() {
|
||||
const mode = tone()
|
||||
const win = new BrowserWindow({
|
||||
width: 640,
|
||||
height: 480,
|
||||
resizable: false,
|
||||
center: true,
|
||||
show: true,
|
||||
icon: iconPath(),
|
||||
backgroundColor,
|
||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
||||
...(process.platform === "win32"
|
||||
? {
|
||||
frame: false,
|
||||
titleBarStyle: "hidden" as const,
|
||||
titleBarOverlay: overlay({ mode }),
|
||||
}
|
||||
: {}),
|
||||
webPreferences: {
|
||||
preload: join(root, "../preload/index.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
|
||||
loadWindow(win, "loading.html")
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
export function registerRendererProtocol() {
|
||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||
|
||||
protocol.handle(rendererProtocol, (request) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.host !== rendererHost) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
|
||||
const rel = relative(rendererRoot, file)
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
return new Response("Not found", { status: 404 })
|
||||
}
|
||||
|
||||
return net.fetch(pathToFileURL(file).toString())
|
||||
})
|
||||
}
|
||||
|
||||
function loadWindow(win: BrowserWindow, html: string) {
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL
|
||||
if (devUrl) {
|
||||
const url = new URL(html, devUrl)
|
||||
void win.loadURL(url.toString())
|
||||
return
|
||||
}
|
||||
|
||||
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
|
||||
}
|
||||
function wireZoom(win: BrowserWindow) {
|
||||
win.webContents.setZoomFactor(1)
|
||||
win.webContents.on("zoom-changed", () => {
|
||||
win.webContents.setZoomFactor(1)
|
||||
})
|
||||
}
|
||||
|
||||
function upsertKeyValue(obj: Record<string, any>, keyToChange: string, value: any) {
|
||||
const keyToChangeLower = keyToChange.toLowerCase()
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key.toLowerCase() === keyToChangeLower) {
|
||||
// Reassign old key
|
||||
obj[key] = value
|
||||
// Done
|
||||
return
|
||||
}
|
||||
}
|
||||
// Insert at end instead
|
||||
obj[keyToChange] = value
|
||||
}
|
||||
71
qimingcode/packages/desktop-electron/src/preload/index.ts
Normal file
71
qimingcode/packages/desktop-electron/src/preload/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types"
|
||||
|
||||
const api: ElectronAPI = {
|
||||
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
|
||||
installCli: () => ipcRenderer.invoke("install-cli"),
|
||||
awaitInitialization: (onStep) => {
|
||||
const handler = (_: unknown, step: InitStep) => onStep(step)
|
||||
ipcRenderer.on("init-step", handler)
|
||||
return ipcRenderer.invoke("await-initialization").finally(() => {
|
||||
ipcRenderer.removeListener("init-step", handler)
|
||||
})
|
||||
},
|
||||
getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
|
||||
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
|
||||
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
||||
setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url),
|
||||
getWslConfig: () => ipcRenderer.invoke("get-wsl-config"),
|
||||
setWslConfig: (config) => ipcRenderer.invoke("set-wsl-config", config),
|
||||
getDisplayBackend: () => ipcRenderer.invoke("get-display-backend"),
|
||||
setDisplayBackend: (backend) => ipcRenderer.invoke("set-display-backend", backend),
|
||||
parseMarkdownCommand: (markdown) => ipcRenderer.invoke("parse-markdown", markdown),
|
||||
checkAppExists: (appName) => ipcRenderer.invoke("check-app-exists", appName),
|
||||
wslPath: (path, mode) => ipcRenderer.invoke("wsl-path", path, mode),
|
||||
resolveAppPath: (appName) => ipcRenderer.invoke("resolve-app-path", appName),
|
||||
storeGet: (name, key) => ipcRenderer.invoke("store-get", name, key),
|
||||
storeSet: (name, key, value) => ipcRenderer.invoke("store-set", name, key, value),
|
||||
storeDelete: (name, key) => ipcRenderer.invoke("store-delete", name, key),
|
||||
storeClear: (name) => ipcRenderer.invoke("store-clear", name),
|
||||
storeKeys: (name) => ipcRenderer.invoke("store-keys", name),
|
||||
storeLength: (name) => ipcRenderer.invoke("store-length", name),
|
||||
|
||||
getWindowCount: () => ipcRenderer.invoke("get-window-count"),
|
||||
onSqliteMigrationProgress: (cb) => {
|
||||
const handler = (_: unknown, progress: SqliteMigrationProgress) => cb(progress)
|
||||
ipcRenderer.on("sqlite-migration-progress", handler)
|
||||
return () => ipcRenderer.removeListener("sqlite-migration-progress", handler)
|
||||
},
|
||||
onMenuCommand: (cb) => {
|
||||
const handler = (_: unknown, id: string) => cb(id)
|
||||
ipcRenderer.on("menu-command", handler)
|
||||
return () => ipcRenderer.removeListener("menu-command", handler)
|
||||
},
|
||||
onDeepLink: (cb) => {
|
||||
const handler = (_: unknown, urls: string[]) => cb(urls)
|
||||
ipcRenderer.on("deep-link", handler)
|
||||
return () => ipcRenderer.removeListener("deep-link", handler)
|
||||
},
|
||||
|
||||
openDirectoryPicker: (opts) => ipcRenderer.invoke("open-directory-picker", opts),
|
||||
openFilePicker: (opts) => ipcRenderer.invoke("open-file-picker", opts),
|
||||
saveFilePicker: (opts) => ipcRenderer.invoke("save-file-picker", opts),
|
||||
openLink: (url) => ipcRenderer.send("open-link", url),
|
||||
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
|
||||
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
|
||||
showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
|
||||
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
|
||||
setWindowFocus: () => ipcRenderer.invoke("set-window-focus"),
|
||||
showWindow: () => ipcRenderer.invoke("show-window"),
|
||||
relaunch: () => ipcRenderer.send("relaunch"),
|
||||
getZoomFactor: () => ipcRenderer.invoke("get-zoom-factor"),
|
||||
setZoomFactor: (factor) => ipcRenderer.invoke("set-zoom-factor", factor),
|
||||
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
|
||||
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
|
||||
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
|
||||
checkUpdate: () => ipcRenderer.invoke("check-update"),
|
||||
installUpdate: () => ipcRenderer.invoke("install-update"),
|
||||
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
|
||||
}
|
||||
|
||||
contextBridge.exposeInMainWorld("api", api)
|
||||
79
qimingcode/packages/desktop-electron/src/preload/types.ts
Normal file
79
qimingcode/packages/desktop-electron/src/preload/types.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
|
||||
|
||||
export type ServerReadyData = {
|
||||
url: string
|
||||
username: string | null
|
||||
password: string | null
|
||||
}
|
||||
|
||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
||||
|
||||
export type WslConfig = { enabled: boolean }
|
||||
|
||||
export type LinuxDisplayBackend = "wayland" | "auto"
|
||||
export type TitlebarTheme = {
|
||||
mode: "light" | "dark"
|
||||
}
|
||||
|
||||
export type WindowConfig = {
|
||||
updaterEnabled: boolean
|
||||
}
|
||||
|
||||
export type ElectronAPI = {
|
||||
killSidecar: () => Promise<void>
|
||||
installCli: () => Promise<string>
|
||||
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
||||
getWindowConfig: () => Promise<WindowConfig>
|
||||
consumeInitialDeepLinks: () => Promise<string[]>
|
||||
getDefaultServerUrl: () => Promise<string | null>
|
||||
setDefaultServerUrl: (url: string | null) => Promise<void>
|
||||
getWslConfig: () => Promise<WslConfig>
|
||||
setWslConfig: (config: WslConfig) => Promise<void>
|
||||
getDisplayBackend: () => Promise<LinuxDisplayBackend | null>
|
||||
setDisplayBackend: (backend: LinuxDisplayBackend | null) => Promise<void>
|
||||
parseMarkdownCommand: (markdown: string) => Promise<string>
|
||||
checkAppExists: (appName: string) => Promise<boolean>
|
||||
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
|
||||
resolveAppPath: (appName: string) => Promise<string | null>
|
||||
storeGet: (name: string, key: string) => Promise<string | null>
|
||||
storeSet: (name: string, key: string, value: string) => Promise<void>
|
||||
storeDelete: (name: string, key: string) => Promise<void>
|
||||
storeClear: (name: string) => Promise<void>
|
||||
storeKeys: (name: string) => Promise<string[]>
|
||||
storeLength: (name: string) => Promise<number>
|
||||
|
||||
getWindowCount: () => Promise<number>
|
||||
onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void
|
||||
onMenuCommand: (cb: (id: string) => void) => () => void
|
||||
onDeepLink: (cb: (urls: string[]) => void) => () => void
|
||||
|
||||
openDirectoryPicker: (opts?: {
|
||||
multiple?: boolean
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
}) => Promise<string | string[] | null>
|
||||
openFilePicker: (opts?: {
|
||||
multiple?: boolean
|
||||
title?: string
|
||||
defaultPath?: string
|
||||
accept?: string[]
|
||||
extensions?: string[]
|
||||
}) => Promise<string | string[] | null>
|
||||
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
|
||||
openLink: (url: string) => void
|
||||
openPath: (path: string, app?: string) => Promise<void>
|
||||
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
|
||||
showNotification: (title: string, body?: string) => void
|
||||
getWindowFocused: () => Promise<boolean>
|
||||
setWindowFocus: () => Promise<void>
|
||||
showWindow: () => Promise<void>
|
||||
relaunch: () => void
|
||||
getZoomFactor: () => Promise<number>
|
||||
setZoomFactor: (factor: number) => Promise<void>
|
||||
setTitlebar: (theme: TitlebarTheme) => Promise<void>
|
||||
loadingWindowComplete: () => void
|
||||
runUpdater: (alertOnFail: boolean) => Promise<void>
|
||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||
installUpdate: () => Promise<void>
|
||||
setBackgroundColor: (color: string) => Promise<void>
|
||||
}
|
||||
12
qimingcode/packages/desktop-electron/src/renderer/cli.ts
Normal file
12
qimingcode/packages/desktop-electron/src/renderer/cli.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { initI18n, t } from "./i18n"
|
||||
|
||||
export async function installCli(): Promise<void> {
|
||||
await initI18n()
|
||||
|
||||
try {
|
||||
const path = await window.api.installCli()
|
||||
window.alert(t("desktop.cli.installed.message", { path }))
|
||||
} catch (e) {
|
||||
window.alert(t("desktop.cli.failed.message", { error: String(e) }))
|
||||
}
|
||||
}
|
||||
10
qimingcode/packages/desktop-electron/src/renderer/env.d.ts
vendored
Normal file
10
qimingcode/packages/desktop-electron/src/renderer/env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ElectronAPI } from "../preload/types"
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: ElectronAPI
|
||||
__OPENCODE__?: {
|
||||
deepLinks?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { join, dirname, resolve } from "node:path"
|
||||
import { existsSync } from "node:fs"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const dir = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(dir, "../..")
|
||||
|
||||
const html = async (name: string) => Bun.file(join(dir, name)).text()
|
||||
|
||||
/**
|
||||
* Packaged Electron windows load renderer HTML via the privileged `oc://`
|
||||
* protocol. Root-relative asset paths like `src="/foo.js"` would resolve from
|
||||
* the protocol origin root instead of relative to the current HTML entrypoint.
|
||||
*
|
||||
* All local resource references must use relative paths (`./`).
|
||||
*/
|
||||
describe("electron renderer html", () => {
|
||||
for (const name of ["index.html", "loading.html"]) {
|
||||
describe(name, () => {
|
||||
test("script src attributes use relative paths", async () => {
|
||||
const content = await html(name)
|
||||
const srcs = [...content.matchAll(/\bsrc=["']([^"']+)["']/g)].map((m) => m[1])
|
||||
for (const src of srcs) {
|
||||
expect(src).not.toMatch(/^\/[^/]/)
|
||||
}
|
||||
})
|
||||
|
||||
test("link href attributes use relative paths", async () => {
|
||||
const content = await html(name)
|
||||
const hrefs = [...content.matchAll(/<link[^>]+href=["']([^"']+)["']/g)].map((m) => m[1])
|
||||
for (const href of hrefs) {
|
||||
expect(href).not.toMatch(/^\/[^/]/)
|
||||
}
|
||||
})
|
||||
|
||||
test("no web manifest link (not applicable in Electron)", async () => {
|
||||
const content = await html(name)
|
||||
expect(content).not.toContain('rel="manifest"')
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Vite resolves `publicDir` relative to `root`, not the config file.
|
||||
* This test reads the actual values from electron.vite.config.ts to catch
|
||||
* regressions where the publicDir path no longer resolves correctly
|
||||
* after the renderer root is accounted for.
|
||||
*/
|
||||
describe("electron vite publicDir", () => {
|
||||
test("configured publicDir resolves to a directory with oc-theme-preload.js", async () => {
|
||||
const config = await Bun.file(join(root, "electron.vite.config.ts")).text()
|
||||
const pub = config.match(/publicDir:\s*["']([^"']+)["']/)
|
||||
const rendererRoot = config.match(/root:\s*["']([^"']+)["']/)
|
||||
expect(pub).not.toBeNull()
|
||||
expect(rendererRoot).not.toBeNull()
|
||||
const resolved = resolve(root, rendererRoot![1], pub![1])
|
||||
expect(existsSync(resolved)).toBe(true)
|
||||
expect(existsSync(join(resolved, "oc-theme-preload.js"))).toBe(true)
|
||||
})
|
||||
})
|
||||
26
qimingcode/packages/desktop-electron/src/renderer/i18n/ar.ts
Normal file
26
qimingcode/packages/desktop-electron/src/renderer/i18n/ar.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...",
|
||||
"desktop.menu.installCli": "تثبيت CLI...",
|
||||
"desktop.menu.reloadWebview": "إعادة تحميل Webview",
|
||||
"desktop.menu.restart": "إعادة تشغيل",
|
||||
|
||||
"desktop.dialog.chooseFolder": "اختر مجلدًا",
|
||||
"desktop.dialog.chooseFile": "اختر ملفًا",
|
||||
"desktop.dialog.saveFile": "حفظ ملف",
|
||||
|
||||
"desktop.updater.checkFailed.title": "فشل التحقق من التحديثات",
|
||||
"desktop.updater.checkFailed.message": "فشل التحقق من وجود تحديثات",
|
||||
"desktop.updater.none.title": "لا توجد تحديثات متاحة",
|
||||
"desktop.updater.none.message": "أنت تستخدم بالفعل أحدث إصدار من OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "فشل التحديث",
|
||||
"desktop.updater.downloadFailed.message": "فشل تنزيل التحديث",
|
||||
"desktop.updater.downloaded.title": "تم تنزيل التحديث",
|
||||
"desktop.updater.downloaded.prompt": "تم تنزيل إصدار {{version}} من OpenCode، هل ترغب في تثبيته وإعادة تشغيله؟",
|
||||
"desktop.updater.installFailed.title": "فشل التحديث",
|
||||
"desktop.updater.installFailed.message": "فشل تثبيت التحديث",
|
||||
|
||||
"desktop.cli.installed.title": "تم تثبيت CLI",
|
||||
"desktop.cli.installed.message": "تم تثبيت CLI في {{path}}\n\nأعد تشغيل الطرفية لاستخدام الأمر 'opencode'.",
|
||||
"desktop.cli.failed.title": "فشل التثبيت",
|
||||
"desktop.cli.failed.message": "فشل تثبيت CLI: {{error}}",
|
||||
}
|
||||
27
qimingcode/packages/desktop-electron/src/renderer/i18n/br.ts
Normal file
27
qimingcode/packages/desktop-electron/src/renderer/i18n/br.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "Verificar atualizações...",
|
||||
"desktop.menu.installCli": "Instalar CLI...",
|
||||
"desktop.menu.reloadWebview": "Recarregar Webview",
|
||||
"desktop.menu.restart": "Reiniciar",
|
||||
|
||||
"desktop.dialog.chooseFolder": "Escolher uma pasta",
|
||||
"desktop.dialog.chooseFile": "Escolher um arquivo",
|
||||
"desktop.dialog.saveFile": "Salvar arquivo",
|
||||
|
||||
"desktop.updater.checkFailed.title": "Falha ao verificar atualizações",
|
||||
"desktop.updater.checkFailed.message": "Falha ao verificar atualizações",
|
||||
"desktop.updater.none.title": "Nenhuma atualização disponível",
|
||||
"desktop.updater.none.message": "Você já está usando a versão mais recente do OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "Falha na atualização",
|
||||
"desktop.updater.downloadFailed.message": "Falha ao baixar a atualização",
|
||||
"desktop.updater.downloaded.title": "Atualização baixada",
|
||||
"desktop.updater.downloaded.prompt":
|
||||
"A versão {{version}} do OpenCode foi baixada. Você gostaria de instalá-la e reiniciar?",
|
||||
"desktop.updater.installFailed.title": "Falha na atualização",
|
||||
"desktop.updater.installFailed.message": "Falha ao instalar a atualização",
|
||||
|
||||
"desktop.cli.installed.title": "CLI instalada",
|
||||
"desktop.cli.installed.message": "CLI instalada em {{path}}\n\nReinicie seu terminal para usar o comando 'opencode'.",
|
||||
"desktop.cli.failed.title": "Falha na instalação",
|
||||
"desktop.cli.failed.message": "Falha ao instalar a CLI: {{error}}",
|
||||
}
|
||||
28
qimingcode/packages/desktop-electron/src/renderer/i18n/bs.ts
Normal file
28
qimingcode/packages/desktop-electron/src/renderer/i18n/bs.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "Provjeri ažuriranja...",
|
||||
"desktop.menu.installCli": "Instaliraj CLI...",
|
||||
"desktop.menu.reloadWebview": "Ponovo učitavanje webview-a",
|
||||
"desktop.menu.restart": "Restartuj",
|
||||
|
||||
"desktop.dialog.chooseFolder": "Odaberi folder",
|
||||
"desktop.dialog.chooseFile": "Odaberi datoteku",
|
||||
"desktop.dialog.saveFile": "Sačuvaj datoteku",
|
||||
|
||||
"desktop.updater.checkFailed.title": "Provjera ažuriranja nije uspjela",
|
||||
"desktop.updater.checkFailed.message": "Nije moguće provjeriti ažuriranja",
|
||||
"desktop.updater.none.title": "Nema dostupnog ažuriranja",
|
||||
"desktop.updater.none.message": "Već koristiš najnoviju verziju OpenCode-a",
|
||||
"desktop.updater.downloadFailed.title": "Ažuriranje nije uspjelo",
|
||||
"desktop.updater.downloadFailed.message": "Neuspjelo preuzimanje ažuriranja",
|
||||
"desktop.updater.downloaded.title": "Ažuriranje preuzeto",
|
||||
"desktop.updater.downloaded.prompt":
|
||||
"Verzija {{version}} OpenCode-a je preuzeta. Želiš li da je instaliraš i ponovo pokreneš aplikaciju?",
|
||||
"desktop.updater.installFailed.title": "Ažuriranje nije uspjelo",
|
||||
"desktop.updater.installFailed.message": "Neuspjela instalacija ažuriranja",
|
||||
|
||||
"desktop.cli.installed.title": "CLI instaliran",
|
||||
"desktop.cli.installed.message":
|
||||
"CLI je instaliran u {{path}}\n\nRestartuj terminal da bi koristio komandu 'opencode'.",
|
||||
"desktop.cli.failed.title": "Instalacija nije uspjela",
|
||||
"desktop.cli.failed.message": "Neuspjela instalacija CLI-a: {{error}}",
|
||||
}
|
||||
28
qimingcode/packages/desktop-electron/src/renderer/i18n/da.ts
Normal file
28
qimingcode/packages/desktop-electron/src/renderer/i18n/da.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "Tjek for opdateringer...",
|
||||
"desktop.menu.installCli": "Installer CLI...",
|
||||
"desktop.menu.reloadWebview": "Genindlæs Webview",
|
||||
"desktop.menu.restart": "Genstart",
|
||||
|
||||
"desktop.dialog.chooseFolder": "Vælg en mappe",
|
||||
"desktop.dialog.chooseFile": "Vælg en fil",
|
||||
"desktop.dialog.saveFile": "Gem fil",
|
||||
|
||||
"desktop.updater.checkFailed.title": "Opdateringstjek mislykkedes",
|
||||
"desktop.updater.checkFailed.message": "Kunne ikke tjekke for opdateringer",
|
||||
"desktop.updater.none.title": "Ingen opdatering tilgængelig",
|
||||
"desktop.updater.none.message": "Du bruger allerede den nyeste version af OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "Opdatering mislykkedes",
|
||||
"desktop.updater.downloadFailed.message": "Kunne ikke downloade opdateringen",
|
||||
"desktop.updater.downloaded.title": "Opdatering downloadet",
|
||||
"desktop.updater.downloaded.prompt":
|
||||
"Version {{version}} af OpenCode er blevet downloadet. Vil du installere den og genstarte?",
|
||||
"desktop.updater.installFailed.title": "Opdatering mislykkedes",
|
||||
"desktop.updater.installFailed.message": "Kunne ikke installere opdateringen",
|
||||
|
||||
"desktop.cli.installed.title": "CLI installeret",
|
||||
"desktop.cli.installed.message":
|
||||
"CLI installeret i {{path}}\n\nGenstart din terminal for at bruge 'opencode'-kommandoen.",
|
||||
"desktop.cli.failed.title": "Installation mislykkedes",
|
||||
"desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}",
|
||||
}
|
||||
28
qimingcode/packages/desktop-electron/src/renderer/i18n/de.ts
Normal file
28
qimingcode/packages/desktop-electron/src/renderer/i18n/de.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "Nach Updates suchen...",
|
||||
"desktop.menu.installCli": "CLI installieren...",
|
||||
"desktop.menu.reloadWebview": "Webview neu laden",
|
||||
"desktop.menu.restart": "Neustart",
|
||||
|
||||
"desktop.dialog.chooseFolder": "Ordner auswählen",
|
||||
"desktop.dialog.chooseFile": "Datei auswählen",
|
||||
"desktop.dialog.saveFile": "Datei speichern",
|
||||
|
||||
"desktop.updater.checkFailed.title": "Updateprüfung fehlgeschlagen",
|
||||
"desktop.updater.checkFailed.message": "Updates konnten nicht geprüft werden",
|
||||
"desktop.updater.none.title": "Kein Update verfügbar",
|
||||
"desktop.updater.none.message": "Sie verwenden bereits die neueste Version von OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "Update fehlgeschlagen",
|
||||
"desktop.updater.downloadFailed.message": "Update konnte nicht heruntergeladen werden",
|
||||
"desktop.updater.downloaded.title": "Update heruntergeladen",
|
||||
"desktop.updater.downloaded.prompt":
|
||||
"Version {{version}} von OpenCode wurde heruntergeladen. Möchten Sie sie installieren und neu starten?",
|
||||
"desktop.updater.installFailed.title": "Update fehlgeschlagen",
|
||||
"desktop.updater.installFailed.message": "Update konnte nicht installiert werden",
|
||||
|
||||
"desktop.cli.installed.title": "CLI installiert",
|
||||
"desktop.cli.installed.message":
|
||||
"CLI wurde in {{path}} installiert\n\nStarten Sie Ihr Terminal neu, um den Befehl 'opencode' zu verwenden.",
|
||||
"desktop.cli.failed.title": "Installation fehlgeschlagen",
|
||||
"desktop.cli.failed.message": "CLI konnte nicht installiert werden: {{error}}",
|
||||
}
|
||||
27
qimingcode/packages/desktop-electron/src/renderer/i18n/en.ts
Normal file
27
qimingcode/packages/desktop-electron/src/renderer/i18n/en.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "Check for Updates...",
|
||||
"desktop.menu.installCli": "Install CLI...",
|
||||
"desktop.menu.reloadWebview": "Reload Webview",
|
||||
"desktop.menu.restart": "Restart",
|
||||
|
||||
"desktop.dialog.chooseFolder": "Choose a folder",
|
||||
"desktop.dialog.chooseFile": "Choose a file",
|
||||
"desktop.dialog.saveFile": "Save file",
|
||||
|
||||
"desktop.updater.checkFailed.title": "Update Check Failed",
|
||||
"desktop.updater.checkFailed.message": "Failed to check for updates",
|
||||
"desktop.updater.none.title": "No Update Available",
|
||||
"desktop.updater.none.message": "You are already using the latest version of OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "Update Failed",
|
||||
"desktop.updater.downloadFailed.message": "Failed to download update",
|
||||
"desktop.updater.downloaded.title": "Update Downloaded",
|
||||
"desktop.updater.downloaded.prompt":
|
||||
"Version {{version}} of OpenCode has been downloaded, would you like to install it and relaunch?",
|
||||
"desktop.updater.installFailed.title": "Update Failed",
|
||||
"desktop.updater.installFailed.message": "Failed to install update",
|
||||
|
||||
"desktop.cli.installed.title": "CLI Installed",
|
||||
"desktop.cli.installed.message": "CLI installed to {{path}}\n\nRestart your terminal to use the 'opencode' command.",
|
||||
"desktop.cli.failed.title": "Installation Failed",
|
||||
"desktop.cli.failed.message": "Failed to install CLI: {{error}}",
|
||||
}
|
||||
27
qimingcode/packages/desktop-electron/src/renderer/i18n/es.ts
Normal file
27
qimingcode/packages/desktop-electron/src/renderer/i18n/es.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "Buscar actualizaciones...",
|
||||
"desktop.menu.installCli": "Instalar CLI...",
|
||||
"desktop.menu.reloadWebview": "Recargar Webview",
|
||||
"desktop.menu.restart": "Reiniciar",
|
||||
|
||||
"desktop.dialog.chooseFolder": "Elegir una carpeta",
|
||||
"desktop.dialog.chooseFile": "Elegir un archivo",
|
||||
"desktop.dialog.saveFile": "Guardar archivo",
|
||||
|
||||
"desktop.updater.checkFailed.title": "Comprobación de actualizaciones fallida",
|
||||
"desktop.updater.checkFailed.message": "No se pudieron buscar actualizaciones",
|
||||
"desktop.updater.none.title": "No hay actualizaciones disponibles",
|
||||
"desktop.updater.none.message": "Ya estás usando la versión más reciente de OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "Actualización fallida",
|
||||
"desktop.updater.downloadFailed.message": "No se pudo descargar la actualización",
|
||||
"desktop.updater.downloaded.title": "Actualización descargada",
|
||||
"desktop.updater.downloaded.prompt":
|
||||
"Se ha descargado la versión {{version}} de OpenCode. ¿Quieres instalarla y reiniciar?",
|
||||
"desktop.updater.installFailed.title": "Actualización fallida",
|
||||
"desktop.updater.installFailed.message": "No se pudo instalar la actualización",
|
||||
|
||||
"desktop.cli.installed.title": "CLI instalada",
|
||||
"desktop.cli.installed.message": "CLI instalada en {{path}}\n\nReinicia tu terminal para usar el comando 'opencode'.",
|
||||
"desktop.cli.failed.title": "Instalación fallida",
|
||||
"desktop.cli.failed.message": "No se pudo instalar la CLI: {{error}}",
|
||||
}
|
||||
28
qimingcode/packages/desktop-electron/src/renderer/i18n/fr.ts
Normal file
28
qimingcode/packages/desktop-electron/src/renderer/i18n/fr.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "Vérifier les mises à jour...",
|
||||
"desktop.menu.installCli": "Installer la CLI...",
|
||||
"desktop.menu.reloadWebview": "Recharger la Webview",
|
||||
"desktop.menu.restart": "Redémarrer",
|
||||
|
||||
"desktop.dialog.chooseFolder": "Choisir un dossier",
|
||||
"desktop.dialog.chooseFile": "Choisir un fichier",
|
||||
"desktop.dialog.saveFile": "Enregistrer le fichier",
|
||||
|
||||
"desktop.updater.checkFailed.title": "Échec de la vérification des mises à jour",
|
||||
"desktop.updater.checkFailed.message": "Impossible de vérifier les mises à jour",
|
||||
"desktop.updater.none.title": "Aucune mise à jour disponible",
|
||||
"desktop.updater.none.message": "Vous utilisez déjà la dernière version d'OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "Échec de la mise à jour",
|
||||
"desktop.updater.downloadFailed.message": "Impossible de télécharger la mise à jour",
|
||||
"desktop.updater.downloaded.title": "Mise à jour téléchargée",
|
||||
"desktop.updater.downloaded.prompt":
|
||||
"La version {{version}} d'OpenCode a été téléchargée. Voulez-vous l'installer et redémarrer ?",
|
||||
"desktop.updater.installFailed.title": "Échec de la mise à jour",
|
||||
"desktop.updater.installFailed.message": "Impossible d'installer la mise à jour",
|
||||
|
||||
"desktop.cli.installed.title": "CLI installée",
|
||||
"desktop.cli.installed.message":
|
||||
"CLI installée dans {{path}}\n\nRedémarrez votre terminal pour utiliser la commande 'opencode'.",
|
||||
"desktop.cli.failed.title": "Échec de l'installation",
|
||||
"desktop.cli.failed.message": "Impossible d'installer la CLI : {{error}}",
|
||||
}
|
||||
188
qimingcode/packages/desktop-electron/src/renderer/i18n/index.ts
Normal file
188
qimingcode/packages/desktop-electron/src/renderer/i18n/index.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import * as i18n from "@solid-primitives/i18n"
|
||||
|
||||
import { dict as desktopEn } from "./en"
|
||||
import { dict as desktopZh } from "./zh"
|
||||
import { dict as desktopZht } from "./zht"
|
||||
import { dict as desktopKo } from "./ko"
|
||||
import { dict as desktopDe } from "./de"
|
||||
import { dict as desktopEs } from "./es"
|
||||
import { dict as desktopFr } from "./fr"
|
||||
import { dict as desktopDa } from "./da"
|
||||
import { dict as desktopJa } from "./ja"
|
||||
import { dict as desktopPl } from "./pl"
|
||||
import { dict as desktopRu } from "./ru"
|
||||
import { dict as desktopAr } from "./ar"
|
||||
import { dict as desktopNo } from "./no"
|
||||
import { dict as desktopBr } from "./br"
|
||||
import { dict as desktopBs } from "./bs"
|
||||
|
||||
import { dict as appEn } from "../../../../app/src/i18n/en"
|
||||
import { dict as appZh } from "../../../../app/src/i18n/zh"
|
||||
import { dict as appZht } from "../../../../app/src/i18n/zht"
|
||||
import { dict as appKo } from "../../../../app/src/i18n/ko"
|
||||
import { dict as appDe } from "../../../../app/src/i18n/de"
|
||||
import { dict as appEs } from "../../../../app/src/i18n/es"
|
||||
import { dict as appFr } from "../../../../app/src/i18n/fr"
|
||||
import { dict as appDa } from "../../../../app/src/i18n/da"
|
||||
import { dict as appJa } from "../../../../app/src/i18n/ja"
|
||||
import { dict as appPl } from "../../../../app/src/i18n/pl"
|
||||
import { dict as appRu } from "../../../../app/src/i18n/ru"
|
||||
import { dict as appAr } from "../../../../app/src/i18n/ar"
|
||||
import { dict as appNo } from "../../../../app/src/i18n/no"
|
||||
import { dict as appBr } from "../../../../app/src/i18n/br"
|
||||
import { dict as appBs } from "../../../../app/src/i18n/bs"
|
||||
|
||||
export type Locale =
|
||||
| "en"
|
||||
| "zh"
|
||||
| "zht"
|
||||
| "ko"
|
||||
| "de"
|
||||
| "es"
|
||||
| "fr"
|
||||
| "da"
|
||||
| "ja"
|
||||
| "pl"
|
||||
| "ru"
|
||||
| "ar"
|
||||
| "no"
|
||||
| "br"
|
||||
| "bs"
|
||||
|
||||
type RawDictionary = typeof appEn & typeof desktopEn
|
||||
type Dictionary = i18n.Flatten<RawDictionary>
|
||||
|
||||
const LOCALES: readonly Locale[] = [
|
||||
"en",
|
||||
"zh",
|
||||
"zht",
|
||||
"ko",
|
||||
"de",
|
||||
"es",
|
||||
"fr",
|
||||
"da",
|
||||
"ja",
|
||||
"pl",
|
||||
"ru",
|
||||
"bs",
|
||||
"ar",
|
||||
"no",
|
||||
"br",
|
||||
]
|
||||
|
||||
function detectLocale(): Locale {
|
||||
if (typeof navigator !== "object") return "en"
|
||||
|
||||
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
|
||||
for (const language of languages) {
|
||||
if (!language) continue
|
||||
if (language.toLowerCase().startsWith("en")) return "en"
|
||||
if (language.toLowerCase().startsWith("zh")) {
|
||||
if (language.toLowerCase().includes("hant")) return "zht"
|
||||
return "zh"
|
||||
}
|
||||
if (language.toLowerCase().startsWith("ko")) return "ko"
|
||||
if (language.toLowerCase().startsWith("de")) return "de"
|
||||
if (language.toLowerCase().startsWith("es")) return "es"
|
||||
if (language.toLowerCase().startsWith("fr")) return "fr"
|
||||
if (language.toLowerCase().startsWith("da")) return "da"
|
||||
if (language.toLowerCase().startsWith("ja")) return "ja"
|
||||
if (language.toLowerCase().startsWith("pl")) return "pl"
|
||||
if (language.toLowerCase().startsWith("ru")) return "ru"
|
||||
if (language.toLowerCase().startsWith("ar")) return "ar"
|
||||
if (
|
||||
language.toLowerCase().startsWith("no") ||
|
||||
language.toLowerCase().startsWith("nb") ||
|
||||
language.toLowerCase().startsWith("nn")
|
||||
)
|
||||
return "no"
|
||||
if (language.toLowerCase().startsWith("pt")) return "br"
|
||||
if (language.toLowerCase().startsWith("bs")) return "bs"
|
||||
}
|
||||
|
||||
return "en"
|
||||
}
|
||||
|
||||
function parseLocale(value: unknown): Locale | null {
|
||||
if (!value) return null
|
||||
if (typeof value !== "string") return null
|
||||
if ((LOCALES as readonly string[]).includes(value)) return value as Locale
|
||||
return null
|
||||
}
|
||||
|
||||
function parseRecord(value: unknown) {
|
||||
if (!value || typeof value !== "object") return null
|
||||
if (Array.isArray(value)) return null
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function parseStored(value: unknown) {
|
||||
if (typeof value !== "string") return value
|
||||
try {
|
||||
return JSON.parse(value) as unknown
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function pickLocale(value: unknown): Locale | null {
|
||||
const direct = parseLocale(value)
|
||||
if (direct) return direct
|
||||
|
||||
const record = parseRecord(value)
|
||||
if (!record) return null
|
||||
|
||||
return parseLocale(record.locale)
|
||||
}
|
||||
|
||||
const base = i18n.flatten({ ...appEn, ...desktopEn })
|
||||
|
||||
function build(locale: Locale): Dictionary {
|
||||
if (locale === "en") return base
|
||||
if (locale === "zh") return { ...base, ...i18n.flatten(appZh), ...i18n.flatten(desktopZh) }
|
||||
if (locale === "zht") return { ...base, ...i18n.flatten(appZht), ...i18n.flatten(desktopZht) }
|
||||
if (locale === "de") return { ...base, ...i18n.flatten(appDe), ...i18n.flatten(desktopDe) }
|
||||
if (locale === "es") return { ...base, ...i18n.flatten(appEs), ...i18n.flatten(desktopEs) }
|
||||
if (locale === "fr") return { ...base, ...i18n.flatten(appFr), ...i18n.flatten(desktopFr) }
|
||||
if (locale === "da") return { ...base, ...i18n.flatten(appDa), ...i18n.flatten(desktopDa) }
|
||||
if (locale === "ja") return { ...base, ...i18n.flatten(appJa), ...i18n.flatten(desktopJa) }
|
||||
if (locale === "pl") return { ...base, ...i18n.flatten(appPl), ...i18n.flatten(desktopPl) }
|
||||
if (locale === "ru") return { ...base, ...i18n.flatten(appRu), ...i18n.flatten(desktopRu) }
|
||||
if (locale === "ar") return { ...base, ...i18n.flatten(appAr), ...i18n.flatten(desktopAr) }
|
||||
if (locale === "no") return { ...base, ...i18n.flatten(appNo), ...i18n.flatten(desktopNo) }
|
||||
if (locale === "br") return { ...base, ...i18n.flatten(appBr), ...i18n.flatten(desktopBr) }
|
||||
if (locale === "bs") return { ...base, ...i18n.flatten(appBs), ...i18n.flatten(desktopBs) }
|
||||
return { ...base, ...i18n.flatten(appKo), ...i18n.flatten(desktopKo) }
|
||||
}
|
||||
|
||||
const state = {
|
||||
locale: detectLocale(),
|
||||
dict: base as Dictionary,
|
||||
init: undefined as Promise<Locale> | undefined,
|
||||
}
|
||||
|
||||
state.dict = build(state.locale)
|
||||
|
||||
const translate = i18n.translator(() => state.dict, i18n.resolveTemplate)
|
||||
|
||||
export function t(key: keyof Dictionary, params?: Record<string, string | number>) {
|
||||
return translate(key, params)
|
||||
}
|
||||
|
||||
export function initI18n(): Promise<Locale> {
|
||||
const cached = state.init
|
||||
if (cached) return cached
|
||||
|
||||
const promise = (async () => {
|
||||
const raw = await window.api.storeGet("opencode.global.dat", "language").catch(() => null)
|
||||
const value = parseStored(raw)
|
||||
const next = pickLocale(value) ?? state.locale
|
||||
|
||||
state.locale = next
|
||||
state.dict = build(next)
|
||||
return next
|
||||
})().catch(() => state.locale)
|
||||
|
||||
state.init = promise
|
||||
return promise
|
||||
}
|
||||
28
qimingcode/packages/desktop-electron/src/renderer/i18n/ja.ts
Normal file
28
qimingcode/packages/desktop-electron/src/renderer/i18n/ja.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "アップデートを確認...",
|
||||
"desktop.menu.installCli": "CLI をインストール...",
|
||||
"desktop.menu.reloadWebview": "Webview を再読み込み",
|
||||
"desktop.menu.restart": "再起動",
|
||||
|
||||
"desktop.dialog.chooseFolder": "フォルダーを選択",
|
||||
"desktop.dialog.chooseFile": "ファイルを選択",
|
||||
"desktop.dialog.saveFile": "ファイルを保存",
|
||||
|
||||
"desktop.updater.checkFailed.title": "アップデートの確認に失敗しました",
|
||||
"desktop.updater.checkFailed.message": "アップデートを確認できませんでした",
|
||||
"desktop.updater.none.title": "利用可能なアップデートはありません",
|
||||
"desktop.updater.none.message": "すでに最新バージョンの OpenCode を使用しています",
|
||||
"desktop.updater.downloadFailed.title": "アップデートに失敗しました",
|
||||
"desktop.updater.downloadFailed.message": "アップデートをダウンロードできませんでした",
|
||||
"desktop.updater.downloaded.title": "アップデートをダウンロードしました",
|
||||
"desktop.updater.downloaded.prompt":
|
||||
"OpenCode のバージョン {{version}} がダウンロードされました。インストールして再起動しますか?",
|
||||
"desktop.updater.installFailed.title": "アップデートに失敗しました",
|
||||
"desktop.updater.installFailed.message": "アップデートをインストールできませんでした",
|
||||
|
||||
"desktop.cli.installed.title": "CLI をインストールしました",
|
||||
"desktop.cli.installed.message":
|
||||
"CLI を {{path}} にインストールしました\n\nターミナルを再起動して 'opencode' コマンドを使用してください。",
|
||||
"desktop.cli.failed.title": "インストールに失敗しました",
|
||||
"desktop.cli.failed.message": "CLI のインストールに失敗しました: {{error}}",
|
||||
}
|
||||
27
qimingcode/packages/desktop-electron/src/renderer/i18n/ko.ts
Normal file
27
qimingcode/packages/desktop-electron/src/renderer/i18n/ko.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "업데이트 확인...",
|
||||
"desktop.menu.installCli": "CLI 설치...",
|
||||
"desktop.menu.reloadWebview": "Webview 새로고침",
|
||||
"desktop.menu.restart": "다시 시작",
|
||||
|
||||
"desktop.dialog.chooseFolder": "폴더 선택",
|
||||
"desktop.dialog.chooseFile": "파일 선택",
|
||||
"desktop.dialog.saveFile": "파일 저장",
|
||||
|
||||
"desktop.updater.checkFailed.title": "업데이트 확인 실패",
|
||||
"desktop.updater.checkFailed.message": "업데이트를 확인하지 못했습니다",
|
||||
"desktop.updater.none.title": "사용 가능한 업데이트 없음",
|
||||
"desktop.updater.none.message": "이미 최신 버전의 OpenCode를 사용하고 있습니다",
|
||||
"desktop.updater.downloadFailed.title": "업데이트 실패",
|
||||
"desktop.updater.downloadFailed.message": "업데이트를 다운로드하지 못했습니다",
|
||||
"desktop.updater.downloaded.title": "업데이트 다운로드 완료",
|
||||
"desktop.updater.downloaded.prompt": "OpenCode {{version}} 버전을 다운로드했습니다. 설치하고 다시 실행할까요?",
|
||||
"desktop.updater.installFailed.title": "업데이트 실패",
|
||||
"desktop.updater.installFailed.message": "업데이트를 설치하지 못했습니다",
|
||||
|
||||
"desktop.cli.installed.title": "CLI 설치됨",
|
||||
"desktop.cli.installed.message":
|
||||
"CLI가 {{path}}에 설치되었습니다\n\n터미널을 다시 시작하여 'opencode' 명령을 사용하세요.",
|
||||
"desktop.cli.failed.title": "설치 실패",
|
||||
"desktop.cli.failed.message": "CLI 설치 실패: {{error}}",
|
||||
}
|
||||
28
qimingcode/packages/desktop-electron/src/renderer/i18n/no.ts
Normal file
28
qimingcode/packages/desktop-electron/src/renderer/i18n/no.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "Se etter oppdateringer...",
|
||||
"desktop.menu.installCli": "Installer CLI...",
|
||||
"desktop.menu.reloadWebview": "Last inn Webview på nytt",
|
||||
"desktop.menu.restart": "Start på nytt",
|
||||
|
||||
"desktop.dialog.chooseFolder": "Velg en mappe",
|
||||
"desktop.dialog.chooseFile": "Velg en fil",
|
||||
"desktop.dialog.saveFile": "Lagre fil",
|
||||
|
||||
"desktop.updater.checkFailed.title": "Oppdateringssjekk mislyktes",
|
||||
"desktop.updater.checkFailed.message": "Kunne ikke se etter oppdateringer",
|
||||
"desktop.updater.none.title": "Ingen oppdatering tilgjengelig",
|
||||
"desktop.updater.none.message": "Du bruker allerede den nyeste versjonen av OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "Oppdatering mislyktes",
|
||||
"desktop.updater.downloadFailed.message": "Kunne ikke laste ned oppdateringen",
|
||||
"desktop.updater.downloaded.title": "Oppdatering lastet ned",
|
||||
"desktop.updater.downloaded.prompt":
|
||||
"Versjon {{version}} av OpenCode er lastet ned. Vil du installere den og starte på nytt?",
|
||||
"desktop.updater.installFailed.title": "Oppdatering mislyktes",
|
||||
"desktop.updater.installFailed.message": "Kunne ikke installere oppdateringen",
|
||||
|
||||
"desktop.cli.installed.title": "CLI installert",
|
||||
"desktop.cli.installed.message":
|
||||
"CLI installert til {{path}}\n\nStart terminalen på nytt for å bruke 'opencode'-kommandoen.",
|
||||
"desktop.cli.failed.title": "Installasjon mislyktes",
|
||||
"desktop.cli.failed.message": "Kunne ikke installere CLI: {{error}}",
|
||||
}
|
||||
28
qimingcode/packages/desktop-electron/src/renderer/i18n/pl.ts
Normal file
28
qimingcode/packages/desktop-electron/src/renderer/i18n/pl.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "Sprawdź aktualizacje...",
|
||||
"desktop.menu.installCli": "Zainstaluj CLI...",
|
||||
"desktop.menu.reloadWebview": "Przeładuj Webview",
|
||||
"desktop.menu.restart": "Restartuj",
|
||||
|
||||
"desktop.dialog.chooseFolder": "Wybierz folder",
|
||||
"desktop.dialog.chooseFile": "Wybierz plik",
|
||||
"desktop.dialog.saveFile": "Zapisz plik",
|
||||
|
||||
"desktop.updater.checkFailed.title": "Nie udało się sprawdzić aktualizacji",
|
||||
"desktop.updater.checkFailed.message": "Nie udało się sprawdzić aktualizacji",
|
||||
"desktop.updater.none.title": "Brak dostępnych aktualizacji",
|
||||
"desktop.updater.none.message": "Korzystasz już z najnowszej wersji OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "Aktualizacja nie powiodła się",
|
||||
"desktop.updater.downloadFailed.message": "Nie udało się pobrać aktualizacji",
|
||||
"desktop.updater.downloaded.title": "Aktualizacja pobrana",
|
||||
"desktop.updater.downloaded.prompt":
|
||||
"Pobrano wersję {{version}} OpenCode. Czy chcesz ją zainstalować i uruchomić ponownie?",
|
||||
"desktop.updater.installFailed.title": "Aktualizacja nie powiodła się",
|
||||
"desktop.updater.installFailed.message": "Nie udało się zainstalować aktualizacji",
|
||||
|
||||
"desktop.cli.installed.title": "CLI zainstalowane",
|
||||
"desktop.cli.installed.message":
|
||||
"CLI zainstalowane w {{path}}\n\nUruchom ponownie terminal, aby użyć polecenia 'opencode'.",
|
||||
"desktop.cli.failed.title": "Instalacja nie powiodła się",
|
||||
"desktop.cli.failed.message": "Nie udało się zainstalować CLI: {{error}}",
|
||||
}
|
||||
27
qimingcode/packages/desktop-electron/src/renderer/i18n/ru.ts
Normal file
27
qimingcode/packages/desktop-electron/src/renderer/i18n/ru.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "Проверить обновления...",
|
||||
"desktop.menu.installCli": "Установить CLI...",
|
||||
"desktop.menu.reloadWebview": "Перезагрузить Webview",
|
||||
"desktop.menu.restart": "Перезапустить",
|
||||
|
||||
"desktop.dialog.chooseFolder": "Выберите папку",
|
||||
"desktop.dialog.chooseFile": "Выберите файл",
|
||||
"desktop.dialog.saveFile": "Сохранить файл",
|
||||
|
||||
"desktop.updater.checkFailed.title": "Не удалось проверить обновления",
|
||||
"desktop.updater.checkFailed.message": "Не удалось проверить обновления",
|
||||
"desktop.updater.none.title": "Обновлений нет",
|
||||
"desktop.updater.none.message": "Вы уже используете последнюю версию OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "Обновление не удалось",
|
||||
"desktop.updater.downloadFailed.message": "Не удалось скачать обновление",
|
||||
"desktop.updater.downloaded.title": "Обновление загружено",
|
||||
"desktop.updater.downloaded.prompt": "Версия OpenCode {{version}} загружена. Хотите установить и перезапустить?",
|
||||
"desktop.updater.installFailed.title": "Обновление не удалось",
|
||||
"desktop.updater.installFailed.message": "Не удалось установить обновление",
|
||||
|
||||
"desktop.cli.installed.title": "CLI установлен",
|
||||
"desktop.cli.installed.message":
|
||||
"CLI установлен в {{path}}\n\nПерезапустите терминал, чтобы использовать команду 'opencode'.",
|
||||
"desktop.cli.failed.title": "Ошибка установки",
|
||||
"desktop.cli.failed.message": "Не удалось установить CLI: {{error}}",
|
||||
}
|
||||
26
qimingcode/packages/desktop-electron/src/renderer/i18n/zh.ts
Normal file
26
qimingcode/packages/desktop-electron/src/renderer/i18n/zh.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "检查更新...",
|
||||
"desktop.menu.installCli": "安装 CLI...",
|
||||
"desktop.menu.reloadWebview": "重新加载 Webview",
|
||||
"desktop.menu.restart": "重启",
|
||||
|
||||
"desktop.dialog.chooseFolder": "选择文件夹",
|
||||
"desktop.dialog.chooseFile": "选择文件",
|
||||
"desktop.dialog.saveFile": "保存文件",
|
||||
|
||||
"desktop.updater.checkFailed.title": "检查更新失败",
|
||||
"desktop.updater.checkFailed.message": "无法检查更新",
|
||||
"desktop.updater.none.title": "没有可用更新",
|
||||
"desktop.updater.none.message": "你已经在使用最新版本的 OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "更新失败",
|
||||
"desktop.updater.downloadFailed.message": "无法下载更新",
|
||||
"desktop.updater.downloaded.title": "更新已下载",
|
||||
"desktop.updater.downloaded.prompt": "已下载 OpenCode {{version}} 版本,是否安装并重启?",
|
||||
"desktop.updater.installFailed.title": "更新失败",
|
||||
"desktop.updater.installFailed.message": "无法安装更新",
|
||||
|
||||
"desktop.cli.installed.title": "CLI 已安装",
|
||||
"desktop.cli.installed.message": "CLI 已安装到 {{path}}\n\n重启终端以使用 'opencode' 命令。",
|
||||
"desktop.cli.failed.title": "安装失败",
|
||||
"desktop.cli.failed.message": "无法安装 CLI: {{error}}",
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const dict = {
|
||||
"desktop.menu.checkForUpdates": "檢查更新...",
|
||||
"desktop.menu.installCli": "安裝 CLI...",
|
||||
"desktop.menu.reloadWebview": "重新載入 Webview",
|
||||
"desktop.menu.restart": "重新啟動",
|
||||
|
||||
"desktop.dialog.chooseFolder": "選擇資料夾",
|
||||
"desktop.dialog.chooseFile": "選擇檔案",
|
||||
"desktop.dialog.saveFile": "儲存檔案",
|
||||
|
||||
"desktop.updater.checkFailed.title": "檢查更新失敗",
|
||||
"desktop.updater.checkFailed.message": "無法檢查更新",
|
||||
"desktop.updater.none.title": "沒有可用更新",
|
||||
"desktop.updater.none.message": "你已在使用最新版的 OpenCode",
|
||||
"desktop.updater.downloadFailed.title": "更新失敗",
|
||||
"desktop.updater.downloadFailed.message": "無法下載更新",
|
||||
"desktop.updater.downloaded.title": "更新已下載",
|
||||
"desktop.updater.downloaded.prompt": "已下載 OpenCode {{version}} 版本,是否安裝並重新啟動?",
|
||||
"desktop.updater.installFailed.title": "更新失敗",
|
||||
"desktop.updater.installFailed.message": "無法安裝更新",
|
||||
|
||||
"desktop.cli.installed.title": "CLI 已安裝",
|
||||
"desktop.cli.installed.message": "CLI 已安裝到 {{path}}\n\n重新啟動終端機以使用 'opencode' 命令。",
|
||||
"desktop.cli.failed.title": "安裝失敗",
|
||||
"desktop.cli.failed.message": "無法安裝 CLI: {{error}}",
|
||||
}
|
||||
22
qimingcode/packages/desktop-electron/src/renderer/index.html
Normal file
22
qimingcode/packages/desktop-electron/src/renderer/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="background-color: var(--background-base)">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenCode</title>
|
||||
<link rel="icon" type="image/png" href="./favicon-96x96-v3.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon-v3.svg" />
|
||||
<link rel="shortcut icon" href="./favicon-v3.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
|
||||
<meta name="theme-color" content="#F8F7F7" />
|
||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||
<meta property="og:image" content="./social-share.png" />
|
||||
<meta property="twitter:image" content="./social-share.png" />
|
||||
<script id="oc-theme-preload-script" src="./oc-theme-preload.js"></script>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="flex flex-col h-dvh"></div>
|
||||
<script src="./index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
357
qimingcode/packages/desktop-electron/src/renderer/index.tsx
Normal file
357
qimingcode/packages/desktop-electron/src/renderer/index.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
// @refresh reload
|
||||
|
||||
import {
|
||||
ACCEPTED_FILE_EXTENSIONS,
|
||||
ACCEPTED_FILE_TYPES,
|
||||
AppBaseProviders,
|
||||
AppInterface,
|
||||
handleNotificationClick,
|
||||
loadLocaleDict,
|
||||
normalizeLocale,
|
||||
type Locale,
|
||||
type Platform,
|
||||
PlatformProvider,
|
||||
ServerConnection,
|
||||
useCommand,
|
||||
} from "@opencode-ai/app"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { MemoryRouter } from "@solidjs/router"
|
||||
import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import pkg from "../../package.json"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { webviewZoom } from "./webview-zoom"
|
||||
import "./styles.css"
|
||||
import { useTheme } from "@opencode-ai/ui/theme"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
|
||||
throw new Error(t("error.dev.rootNotFound"))
|
||||
}
|
||||
|
||||
void initI18n()
|
||||
|
||||
const deepLinkEvent = "opencode:deep-link"
|
||||
|
||||
const emitDeepLinks = (urls: string[]) => {
|
||||
if (urls.length === 0) return
|
||||
window.__OPENCODE__ ??= {}
|
||||
const pending = window.__OPENCODE__.deepLinks ?? []
|
||||
window.__OPENCODE__.deepLinks = [...pending, ...urls]
|
||||
window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
|
||||
}
|
||||
|
||||
const listenForDeepLinks = () => {
|
||||
void window.api.consumeInitialDeepLinks().then((urls) => emitDeepLinks(urls))
|
||||
return window.api.onDeepLink((urls) => emitDeepLinks(urls))
|
||||
}
|
||||
|
||||
const createPlatform = (): Platform => {
|
||||
const os = (() => {
|
||||
const ua = navigator.userAgent
|
||||
if (ua.includes("Mac")) return "macos"
|
||||
if (ua.includes("Windows")) return "windows"
|
||||
if (ua.includes("Linux")) return "linux"
|
||||
return undefined
|
||||
})()
|
||||
|
||||
const isWslEnabled = async () => {
|
||||
if (os !== "windows") return false
|
||||
return window.api
|
||||
.getWslConfig()
|
||||
.then((config) => config.enabled)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
const wslHome = async () => {
|
||||
if (!(await isWslEnabled())) return undefined
|
||||
return window.api.wslPath("~", "windows").catch(() => undefined)
|
||||
}
|
||||
|
||||
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
|
||||
if (!result || !(await isWslEnabled())) return result
|
||||
if (Array.isArray(result)) {
|
||||
return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any
|
||||
}
|
||||
return window.api.wslPath(result, "linux").catch(() => result) as any
|
||||
}
|
||||
|
||||
const storage = (() => {
|
||||
const cache = new Map<string, AsyncStorage>()
|
||||
|
||||
const createStorage = (name: string) => {
|
||||
const api: AsyncStorage = {
|
||||
getItem: (key: string) => window.api.storeGet(name, key),
|
||||
setItem: (key: string, value: string) => window.api.storeSet(name, key, value),
|
||||
removeItem: (key: string) => window.api.storeDelete(name, key),
|
||||
clear: () => window.api.storeClear(name),
|
||||
key: async (index: number) => (await window.api.storeKeys(name))[index],
|
||||
getLength: () => window.api.storeLength(name),
|
||||
get length() {
|
||||
return api.getLength()
|
||||
},
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
return (name = "default.dat") => {
|
||||
const cached = cache.get(name)
|
||||
if (cached) return cached
|
||||
const api = createStorage(name)
|
||||
cache.set(name, api)
|
||||
return api
|
||||
}
|
||||
})()
|
||||
|
||||
return {
|
||||
platform: "desktop",
|
||||
os,
|
||||
version: pkg.version,
|
||||
|
||||
async openDirectoryPickerDialog(opts) {
|
||||
const defaultPath = await wslHome()
|
||||
const result = await window.api.openDirectoryPicker({
|
||||
multiple: opts?.multiple ?? false,
|
||||
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
|
||||
defaultPath,
|
||||
})
|
||||
return await handleWslPicker(result)
|
||||
},
|
||||
|
||||
async openFilePickerDialog(opts) {
|
||||
const result = await window.api.openFilePicker({
|
||||
multiple: opts?.multiple ?? false,
|
||||
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
||||
accept: opts?.accept ?? ACCEPTED_FILE_TYPES,
|
||||
extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS,
|
||||
})
|
||||
return handleWslPicker(result)
|
||||
},
|
||||
|
||||
async saveFilePickerDialog(opts) {
|
||||
const result = await window.api.saveFilePicker({
|
||||
title: opts?.title ?? t("desktop.dialog.saveFile"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
return handleWslPicker(result)
|
||||
},
|
||||
|
||||
openLink(url: string) {
|
||||
window.api.openLink(url)
|
||||
},
|
||||
async openPath(path: string, app?: string) {
|
||||
if (os === "windows") {
|
||||
const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null
|
||||
const resolvedPath = await (async () => {
|
||||
if (await isWslEnabled()) {
|
||||
const converted = await window.api.wslPath(path, "windows").catch(() => null)
|
||||
if (converted) return converted
|
||||
}
|
||||
return path
|
||||
})()
|
||||
return window.api.openPath(resolvedPath, resolvedApp ?? undefined)
|
||||
}
|
||||
return window.api.openPath(path, app)
|
||||
},
|
||||
|
||||
back() {
|
||||
window.history.back()
|
||||
},
|
||||
|
||||
forward() {
|
||||
window.history.forward()
|
||||
},
|
||||
|
||||
storage,
|
||||
|
||||
checkUpdate: async () => {
|
||||
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
|
||||
if (!config.updaterEnabled) return { updateAvailable: false }
|
||||
return window.api.checkUpdate()
|
||||
},
|
||||
|
||||
updateAndRestart: async () => {
|
||||
const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))
|
||||
if (!config.updaterEnabled) return
|
||||
await window.api.installUpdate()
|
||||
},
|
||||
|
||||
restart: async () => {
|
||||
await window.api.killSidecar().catch(() => undefined)
|
||||
window.api.relaunch()
|
||||
},
|
||||
|
||||
notify: async (title, description, href) => {
|
||||
const focused = await window.api.getWindowFocused().catch(() => document.hasFocus())
|
||||
if (focused) return
|
||||
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96-v3.png",
|
||||
})
|
||||
notification.onclick = () => {
|
||||
void window.api.showWindow()
|
||||
void window.api.setWindowFocus()
|
||||
handleNotificationClick(href)
|
||||
notification.close()
|
||||
}
|
||||
},
|
||||
|
||||
fetch: (input, init) => {
|
||||
if (input instanceof Request) return fetch(input)
|
||||
return fetch(input, init)
|
||||
},
|
||||
|
||||
getWslEnabled: () => isWslEnabled(),
|
||||
|
||||
setWslEnabled: async (enabled) => {
|
||||
await window.api.setWslConfig({ enabled })
|
||||
},
|
||||
|
||||
getDefaultServer: async () => {
|
||||
const url = await window.api.getDefaultServerUrl().catch(() => null)
|
||||
if (!url) return null
|
||||
return ServerConnection.Key.make(url)
|
||||
},
|
||||
|
||||
setDefaultServer: async (url: string | null) => {
|
||||
await window.api.setDefaultServerUrl(url)
|
||||
},
|
||||
|
||||
getDisplayBackend: async () => {
|
||||
return window.api.getDisplayBackend().catch(() => null)
|
||||
},
|
||||
|
||||
setDisplayBackend: async (backend) => {
|
||||
await window.api.setDisplayBackend(backend)
|
||||
},
|
||||
|
||||
parseMarkdown: (markdown: string) => window.api.parseMarkdownCommand(markdown),
|
||||
|
||||
webviewZoom,
|
||||
|
||||
checkAppExists: async (appName: string) => {
|
||||
return window.api.checkAppExists(appName)
|
||||
},
|
||||
|
||||
async readClipboardImage() {
|
||||
const image = await window.api.readClipboardImage().catch(() => null)
|
||||
if (!image) return null
|
||||
const blob = new Blob([image.buffer], { type: "image/png" })
|
||||
return new File([blob], `pasted-image-${Date.now()}.png`, {
|
||||
type: "image/png",
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let menuTrigger = null as null | ((id: string) => void)
|
||||
window.api.onMenuCommand((id) => {
|
||||
menuTrigger?.(id)
|
||||
})
|
||||
listenForDeepLinks()
|
||||
|
||||
render(() => {
|
||||
const platform = createPlatform()
|
||||
const [windowConfig] = createResource(() => window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })))
|
||||
const loadLocale = async () => {
|
||||
const current = await platform.storage?.("opencode.global.dat").getItem("language")
|
||||
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
|
||||
const raw = current ?? legacy
|
||||
if (!raw) return
|
||||
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
|
||||
if (!locale) return
|
||||
const next = normalizeLocale(locale)
|
||||
if (next !== "en") await loadLocaleDict(next)
|
||||
return next satisfies Locale
|
||||
}
|
||||
|
||||
const [windowCount] = createResource(() => window.api.getWindowCount())
|
||||
|
||||
// Fetch sidecar credentials (available immediately, before health check)
|
||||
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
|
||||
|
||||
const [defaultServer] = createResource(() =>
|
||||
platform.getDefaultServer?.().then((url) => {
|
||||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||
}),
|
||||
)
|
||||
const [locale] = createResource(loadLocale)
|
||||
|
||||
const servers = () => {
|
||||
const data = sidecar()
|
||||
if (!data) return []
|
||||
const server: ServerConnection.Sidecar = {
|
||||
displayName: "Local Server",
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: {
|
||||
url: data.url,
|
||||
username: data.username ?? undefined,
|
||||
password: data.password ?? undefined,
|
||||
},
|
||||
}
|
||||
return [server] as ServerConnection.Any[]
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||
if (link?.href) {
|
||||
e.preventDefault()
|
||||
platform.openLink(link.href)
|
||||
}
|
||||
}
|
||||
|
||||
function Inner() {
|
||||
const cmd = useCommand()
|
||||
menuTrigger = (id) => cmd.trigger(id)
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
createEffect(() => {
|
||||
theme.themeId()
|
||||
theme.mode()
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue("--background-base").trim()
|
||||
if (bg) {
|
||||
void window.api.setBackgroundColor(bg)
|
||||
}
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("click", handleClick)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("click", handleClick)
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders locale={locale.latest}>
|
||||
<Show
|
||||
when={
|
||||
!defaultServer.loading &&
|
||||
!sidecar.loading &&
|
||||
!windowConfig.loading &&
|
||||
!windowCount.loading &&
|
||||
!locale.loading
|
||||
}
|
||||
>
|
||||
{(_) => {
|
||||
return (
|
||||
<AppInterface
|
||||
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
|
||||
servers={servers()}
|
||||
router={MemoryRouter}
|
||||
>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
}, root!)
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="en" style="background-color: var(--background-base)">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenCode</title>
|
||||
<link rel="icon" type="image/png" href="./favicon-96x96-v3.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon-v3.svg" />
|
||||
<link rel="shortcut icon" href="./favicon-v3.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
|
||||
<meta name="theme-color" content="#F8F7F7" />
|
||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||
<meta property="og:image" content="./social-share.png" />
|
||||
<meta property="twitter:image" content="./social-share.png" />
|
||||
<script id="oc-theme-preload-script" src="./oc-theme-preload.js"></script>
|
||||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="flex flex-col h-dvh"></div>
|
||||
<script src="./loading.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { render } from "solid-js/web"
|
||||
import "@opencode-ai/app/index.css"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { Progress } from "@opencode-ai/ui/progress"
|
||||
import "./styles.css"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import type { InitStep, SqliteMigrationProgress } from "../preload/types"
|
||||
|
||||
const root = document.getElementById("root")!
|
||||
const lines = ["Just a moment...", "Migrating your database", "This may take a couple of minutes"]
|
||||
const delays = [3000, 9000]
|
||||
|
||||
render(() => {
|
||||
const [step, setStep] = createSignal<InitStep | null>(null)
|
||||
const [line, setLine] = createSignal(0)
|
||||
const [percent, setPercent] = createSignal(0)
|
||||
|
||||
const phase = createMemo(() => step()?.phase)
|
||||
|
||||
const value = createMemo(() => {
|
||||
if (phase() === "done") return 100
|
||||
return Math.max(25, Math.min(100, percent()))
|
||||
})
|
||||
|
||||
window.api.awaitInitialization((next) => setStep(next as InitStep)).catch(() => undefined)
|
||||
|
||||
onMount(() => {
|
||||
setLine(0)
|
||||
setPercent(0)
|
||||
|
||||
const timers = delays.map((ms, i) => setTimeout(() => setLine(i + 1), ms))
|
||||
|
||||
const listener = window.api.onSqliteMigrationProgress((progress: SqliteMigrationProgress) => {
|
||||
if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
|
||||
if (progress.type === "Done") {
|
||||
setPercent(100)
|
||||
setStep({ phase: "done" })
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
listener()
|
||||
timers.forEach(clearTimeout)
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (phase() !== "done") return
|
||||
|
||||
const timer = setTimeout(() => window.api.loadingWindowComplete(), 1000)
|
||||
onCleanup(() => clearTimeout(timer))
|
||||
})
|
||||
|
||||
const status = createMemo(() => {
|
||||
if (phase() === "done") return "All done"
|
||||
if (phase() === "sqlite_waiting") return lines[line()]
|
||||
return "Just a moment..."
|
||||
})
|
||||
|
||||
return (
|
||||
<MetaProvider>
|
||||
<div class="w-screen h-screen bg-background-base flex items-center justify-center">
|
||||
<Font />
|
||||
<div class="flex flex-col items-center gap-11">
|
||||
<Splash class="w-20 h-25 opacity-15" />
|
||||
<div class="w-60 flex flex-col items-center gap-4" aria-live="polite">
|
||||
<span class="w-full overflow-hidden text-center text-ellipsis whitespace-nowrap text-text-strong text-14-normal">
|
||||
{status()}
|
||||
</span>
|
||||
<Progress
|
||||
value={value()}
|
||||
class="w-20 [&_[data-slot='progress-track']]:h-1 [&_[data-slot='progress-track']]:border-0 [&_[data-slot='progress-track']]:rounded-none [&_[data-slot='progress-track']]:bg-surface-weak [&_[data-slot='progress-fill']]:rounded-none [&_[data-slot='progress-fill']]:bg-icon-warning-base"
|
||||
aria-label="Database migration progress"
|
||||
getValueLabel={({ value }) => `${Math.round(value)}%`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MetaProvider>
|
||||
)
|
||||
}, root)
|
||||
12
qimingcode/packages/desktop-electron/src/renderer/updater.ts
Normal file
12
qimingcode/packages/desktop-electron/src/renderer/updater.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { initI18n, t } from "./i18n"
|
||||
|
||||
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
|
||||
await initI18n()
|
||||
try {
|
||||
await window.api.runUpdater(alertOnFail)
|
||||
} catch {
|
||||
if (alertOnFail) {
|
||||
window.alert(t("desktop.updater.checkFailed.message"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
|
||||
const OS_NAME = (() => {
|
||||
if (navigator.userAgent.includes("Mac")) return "macos"
|
||||
if (navigator.userAgent.includes("Windows")) return "windows"
|
||||
if (navigator.userAgent.includes("Linux")) return "linux"
|
||||
return "unknown"
|
||||
})()
|
||||
|
||||
const [webviewZoom, setWebviewZoom] = createSignal(1)
|
||||
|
||||
const MAX_ZOOM_LEVEL = 10
|
||||
const MIN_ZOOM_LEVEL = 0.2
|
||||
|
||||
const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
|
||||
|
||||
const applyZoom = (next: number) => {
|
||||
setWebviewZoom(next)
|
||||
void window.api.setZoomFactor(next)
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
|
||||
|
||||
let newZoom = webviewZoom()
|
||||
|
||||
if (event.key === "-") newZoom -= 0.2
|
||||
if (event.key === "=" || event.key === "+") newZoom += 0.2
|
||||
if (event.key === "0") newZoom = 1
|
||||
|
||||
applyZoom(clamp(newZoom))
|
||||
})
|
||||
|
||||
export { webviewZoom }
|
||||
Reference in New Issue
Block a user