Compare commits
4 commits
dev
...
brendan/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf353e8c77 |
||
|
|
36d8973925 |
||
|
|
41cf0f36bd |
||
|
|
1821714479 |
9 changed files with 372 additions and 296 deletions
1
bun.lock
1
bun.lock
|
|
@ -230,6 +230,7 @@
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.14.35",
|
"version": "1.14.35",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@effect/platform-node": "catalog:",
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"electron-context-menu": "4.1.2",
|
"electron-context-menu": "4.1.2",
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
|
"@effect/platform-node": "catalog:",
|
||||||
"electron-context-menu": "4.1.2",
|
"electron-context-menu": "4.1.2",
|
||||||
"electron-log": "^5",
|
"electron-log": "^5",
|
||||||
"electron-store": "^10",
|
"electron-store": "^10",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { EventEmitter } from "node:events"
|
|
||||||
import { existsSync } from "node:fs"
|
import { existsSync } from "node:fs"
|
||||||
import { createServer } from "node:net"
|
import { createServer } from "node:net"
|
||||||
import { homedir } from "node:os"
|
import { homedir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import type { Event } from "electron"
|
|
||||||
import { app, BrowserWindow, dialog } from "electron"
|
import { app, BrowserWindow, dialog } from "electron"
|
||||||
|
import * as NodeRuntime from "@effect/platform-node/NodeRuntime"
|
||||||
|
import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
|
||||||
import pkg from "electron-updater"
|
import pkg from "electron-updater"
|
||||||
|
import { Data, Deferred, Effect, Fiber, Option, PubSub, Queue, Ref, Stream, SubscriptionRef } from "effect"
|
||||||
|
|
||||||
import contextMenu from "electron-context-menu"
|
import contextMenu from "electron-context-menu"
|
||||||
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
|
||||||
|
|
@ -34,14 +35,15 @@ app.setAppUserModelId(appId)
|
||||||
app.setPath("userData", join(app.getPath("appData"), appId))
|
app.setPath("userData", join(app.getPath("appData"), appId))
|
||||||
const { autoUpdater } = pkg
|
const { autoUpdater } = pkg
|
||||||
|
|
||||||
import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
import { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
|
||||||
|
import type { Server } from "virtual:opencode-server"
|
||||||
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
||||||
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
|
||||||
import { initLogging } from "./logging"
|
import { initLogging } from "./logging"
|
||||||
import { parseMarkdown } from "./markdown"
|
import { parseMarkdown } from "./markdown"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server"
|
import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServerEffect } from "./server"
|
||||||
import {
|
import {
|
||||||
createLoadingWindow,
|
createLoadingWindow,
|
||||||
createMainWindow,
|
createMainWindow,
|
||||||
|
|
@ -49,233 +51,188 @@ import {
|
||||||
setBackgroundColor,
|
setBackgroundColor,
|
||||||
setDockIcon,
|
setDockIcon,
|
||||||
} from "./windows"
|
} from "./windows"
|
||||||
import { drizzle } from "drizzle-orm/node-sqlite/driver"
|
|
||||||
import type { Server } from "virtual:opencode-server"
|
|
||||||
import { migrate } from "./migrate"
|
|
||||||
|
|
||||||
const initEmitter = new EventEmitter()
|
// ---------------------------------------------------------------------------
|
||||||
let initStep: InitStep = { phase: "server_waiting" }
|
// State — individual pieces, synchronously allocated at module load.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null
|
const initStep = Effect.runSync(SubscriptionRef.make<InitStep>(InitStep.ServerWaiting()))
|
||||||
let server: Server.Listener | null = null
|
const serverReady = Deferred.makeUnsafe<ServerReadyData>()
|
||||||
const loadingComplete = defer<void>()
|
const loadingComplete = Deferred.makeUnsafe<void>()
|
||||||
|
const deepLinkQueue = Effect.runSync(Queue.unbounded<string[]>())
|
||||||
|
const deepLinksConsumed = Deferred.makeUnsafe<void>()
|
||||||
|
const server = Ref.makeUnsafe<Option.Option<Server.Listener>>(Option.none())
|
||||||
|
const menuCommands = Effect.runSync(PubSub.unbounded<string>())
|
||||||
|
const sqliteProgress = Effect.runSync(PubSub.unbounded<SqliteMigrationProgress>())
|
||||||
|
|
||||||
const pendingDeepLinks: string[] = []
|
// ---------------------------------------------------------------------------
|
||||||
|
// App events (Data.TaggedEnum)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const serverReady = defer<ServerReadyData>()
|
type AppEvent = Data.TaggedEnum<{
|
||||||
const logger = initLogging()
|
SecondInstance: { readonly argv: readonly string[] }
|
||||||
|
OpenUrl: { readonly url: string }
|
||||||
|
BeforeQuit: {}
|
||||||
|
WillQuit: {}
|
||||||
|
}>
|
||||||
|
|
||||||
logger.log("app starting", {
|
const appEvent = Data.taggedEnum<AppEvent>()
|
||||||
version: app.getVersion(),
|
|
||||||
packaged: app.isPackaged,
|
|
||||||
})
|
|
||||||
|
|
||||||
setupApp()
|
const handleAppEvent = (
|
||||||
|
event: AppEvent,
|
||||||
|
deepLinkQueue: Queue.Queue<string[]>,
|
||||||
|
mainWindow: BrowserWindow,
|
||||||
|
server: Ref.Ref<Option.Option<Server.Listener>>,
|
||||||
|
) =>
|
||||||
|
appEvent.$match(event, {
|
||||||
|
SecondInstance: ({ argv }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const urls = argv.filter((arg) => arg.startsWith("opencode://"))
|
||||||
|
if (urls.length) {
|
||||||
|
logger.log("deep link received via second-instance", { urls })
|
||||||
|
yield* Queue.offer(deepLinkQueue, urls)
|
||||||
|
}
|
||||||
|
focusMainWindow(mainWindow)
|
||||||
|
}),
|
||||||
|
OpenUrl: ({ url }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
logger.log("deep link received via open-url", { url })
|
||||||
|
yield* Queue.offer(deepLinkQueue, [url])
|
||||||
|
}),
|
||||||
|
BeforeQuit: () => stopServer(server),
|
||||||
|
WillQuit: () => stopServer(server),
|
||||||
|
})
|
||||||
|
|
||||||
function setupApp() {
|
const focusMainWindow = (win: BrowserWindow) => {
|
||||||
ensureLoopbackNoProxy()
|
win.show()
|
||||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
win.focus()
|
||||||
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
|
}
|
||||||
|
|
||||||
if (!app.requestSingleInstanceLock()) {
|
const stopServer = (ref: Ref.Ref<Option.Option<Server.Listener>>) =>
|
||||||
app.quit()
|
Effect.gen(function* () {
|
||||||
return
|
const srv = yield* Ref.get(ref)
|
||||||
}
|
if (Option.isSome(srv)) {
|
||||||
|
yield* Effect.promise(() => srv.value.stop())
|
||||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
yield* Ref.set(ref, Option.none())
|
||||||
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) => {
|
const initialize = Effect.fn("Main.initialize")(function* () {
|
||||||
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 () => {
|
|
||||||
migrate()
|
|
||||||
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 needsMigration = !sqliteFileExists()
|
||||||
const sqliteDone = needsMigration ? defer<void>() : undefined
|
|
||||||
let overlay: BrowserWindow | null = null
|
|
||||||
|
|
||||||
const port = await getSidecarPort()
|
const port = yield* getSidecarPort
|
||||||
const hostname = "127.0.0.1"
|
const hostname = "127.0.0.1"
|
||||||
const url = `http://${hostname}:${port}`
|
const url = `http://${hostname}:${port}`
|
||||||
const password = randomUUID()
|
const password = randomUUID()
|
||||||
|
|
||||||
const loadingTask = (async () => {
|
const loadingFiber = yield* Effect.gen(function* () {
|
||||||
logger.log("sidecar connection started", { url })
|
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) {
|
if (needsMigration) {
|
||||||
const { Database, JsonMigration } = await import("virtual:opencode-server")
|
const { Database, JsonMigration } = yield* Effect.promise(
|
||||||
await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
|
() => import("virtual:opencode-server") as Promise<typeof import("virtual:opencode-server")>,
|
||||||
progress: (event: { current: number; total: number }) => {
|
)
|
||||||
const percent = Math.round(event.current / event.total) * 100
|
const client = Database.Client().$client
|
||||||
initEmitter.emit("sqlite", { type: "InProgress", value: percent })
|
const db = yield* Effect.promise(() =>
|
||||||
},
|
import("drizzle-orm/node-sqlite/driver").then((m) => m.drizzle({ client })),
|
||||||
})
|
)
|
||||||
initEmitter.emit("sqlite", { type: "Done" })
|
|
||||||
|
|
||||||
sqliteDone?.resolve()
|
yield* SubscriptionRef.set(initStep, InitStep.SqliteWaiting())
|
||||||
}
|
|
||||||
|
|
||||||
if (needsMigration) {
|
yield* Effect.promise(() =>
|
||||||
await sqliteDone?.promise
|
JsonMigration.run(db, {
|
||||||
|
progress: (event: { current: number; total: number }) => {
|
||||||
|
const percent = Math.round((event.current / event.total) * 100)
|
||||||
|
const progress: SqliteMigrationProgress = { type: "InProgress", value: percent }
|
||||||
|
if (Option.isSome(overlay)) sendSqliteMigrationProgress(overlay.value, progress)
|
||||||
|
void Effect.runPromise(PubSub.publish(sqliteProgress, progress))
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* PubSub.publish(sqliteProgress, { type: "Done" })
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log("spawning sidecar", { url })
|
logger.log("spawning sidecar", { url })
|
||||||
const { listener, health } = await spawnLocalServer(hostname, port, password)
|
const { listener, health } = yield* spawnLocalServerEffect(hostname, port, password)
|
||||||
server = listener
|
yield* Ref.set(server, Option.some(listener))
|
||||||
serverReady.resolve({
|
|
||||||
|
yield* Deferred.succeed(serverReady, {
|
||||||
url,
|
url,
|
||||||
username: "opencode",
|
username: "opencode",
|
||||||
password,
|
password,
|
||||||
})
|
})
|
||||||
|
|
||||||
await Promise.race([
|
yield* Effect.raceAll([
|
||||||
health.wait,
|
health,
|
||||||
delay(30_000).then(() => {
|
Effect.sleep("30 seconds").pipe(Effect.flatMap(() => Effect.fail(new Error("Sidecar health check timed out")))),
|
||||||
throw new Error("Sidecar health check timed out")
|
]).pipe(Effect.catch((error) => Effect.sync(() => logger.error("sidecar health check failed", error))))
|
||||||
}),
|
|
||||||
]).catch((error) => {
|
|
||||||
logger.error("sidecar health check failed", error)
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.log("loading task finished")
|
logger.log("loading task finished")
|
||||||
})()
|
|
||||||
|
|
||||||
if (needsMigration) {
|
return listener
|
||||||
const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)])
|
}).pipe(Effect.forkChild)
|
||||||
if (show) {
|
|
||||||
overlay = createLoadingWindow()
|
const overlay = yield* Effect.gen(function* () {
|
||||||
await delay(1_000)
|
if (!needsMigration) return
|
||||||
}
|
|
||||||
|
const show = yield* Effect.raceAll([
|
||||||
|
Fiber.join(loadingFiber).pipe(Effect.as(false)),
|
||||||
|
Effect.sleep("1 second").pipe(Effect.as(true)),
|
||||||
|
])
|
||||||
|
if (!show) return
|
||||||
|
|
||||||
|
const overlay = createLoadingWindow()
|
||||||
|
yield* Effect.sleep("1 second")
|
||||||
|
return overlay
|
||||||
|
}).pipe(Effect.map(Option.fromNullishOr))
|
||||||
|
|
||||||
|
yield* Fiber.join(loadingFiber)
|
||||||
|
yield* SubscriptionRef.set(initStep, InitStep.Done())
|
||||||
|
|
||||||
|
if (Option.isSome(overlay)) {
|
||||||
|
yield* Deferred.await(loadingComplete)
|
||||||
|
overlay.value.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// App lifecycle (imperative Electron shell, thin wrappers around Effects)
|
||||||
server.stop()
|
// ---------------------------------------------------------------------------
|
||||||
server = null
|
|
||||||
|
const logger = initLogging()
|
||||||
|
|
||||||
|
const shutdown = Effect.gen(function* () {
|
||||||
|
yield* stopServer(server)
|
||||||
|
app.exit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const registerAppEventListeners = (appEvents: PubSub.PubSub<AppEvent>) => () => {
|
||||||
|
app.on("second-instance", (_event, argv) => {
|
||||||
|
PubSub.publishUnsafe(appEvents, appEvent.SecondInstance({ argv }))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on("open-url", (event, url) => {
|
||||||
|
event.preventDefault()
|
||||||
|
PubSub.publishUnsafe(appEvents, appEvent.OpenUrl({ url }))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on("before-quit", () => {
|
||||||
|
PubSub.publishUnsafe(appEvents, appEvent.BeforeQuit())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on("will-quit", () => {
|
||||||
|
PubSub.publishUnsafe(appEvents, appEvent.WillQuit())
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||||
|
process.on(signal, () => {
|
||||||
|
void Effect.runPromise(shutdown)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureLoopbackNoProxy() {
|
const ensureLoopbackNoProxy = () => {
|
||||||
const loopback = ["127.0.0.1", "localhost", "::1"]
|
const loopback = ["127.0.0.1", "localhost", "::1"]
|
||||||
const upsert = (key: string) => {
|
const upsert = (key: string) => {
|
||||||
const items = (process.env[key] ?? "")
|
const items = (process.env[key] ?? "")
|
||||||
|
|
@ -295,30 +252,159 @@ function ensureLoopbackNoProxy() {
|
||||||
upsert("no_proxy")
|
upsert("no_proxy")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSidecarPort() {
|
const main = Effect.gen(function* () {
|
||||||
|
logger.log("app starting", {
|
||||||
|
version: app.getVersion(),
|
||||||
|
packaged: app.isPackaged,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.quit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureLoopbackNoProxy()
|
||||||
|
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||||
|
|
||||||
|
const appEvents = yield* PubSub.unbounded<AppEvent>()
|
||||||
|
registerAppEventListeners(appEvents)
|
||||||
|
|
||||||
|
yield* Effect.promise(() => app.whenReady())
|
||||||
|
|
||||||
|
app.setAsDefaultProtocolClient("opencode")
|
||||||
|
registerRendererProtocol()
|
||||||
|
setDockIcon()
|
||||||
|
setupAutoUpdater()
|
||||||
|
|
||||||
|
registerIpcHandlersImpl()
|
||||||
|
|
||||||
|
yield* initialize()
|
||||||
|
|
||||||
|
const mainWindow = createMainWindow()
|
||||||
|
wireMenu(mainWindow)
|
||||||
|
|
||||||
|
yield* Effect.all(
|
||||||
|
[
|
||||||
|
Stream.fromPubSub(appEvents).pipe(
|
||||||
|
Stream.runForEach((event) => handleAppEvent(event, deepLinkQueue, mainWindow, server)),
|
||||||
|
),
|
||||||
|
Deferred.await(deepLinksConsumed).pipe(
|
||||||
|
Effect.andThen(
|
||||||
|
Stream.fromQueue(deepLinkQueue).pipe(
|
||||||
|
Stream.runForEach((urls) => Effect.sync(() => sendDeepLinks(mainWindow, urls))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Stream.fromPubSub(menuCommands).pipe(
|
||||||
|
Stream.runForEach((id) => Effect.sync(() => sendMenuCommand(mainWindow, id))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
}).pipe(
|
||||||
|
Effect.catch((error) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
logger.error("initialization failed", error)
|
||||||
|
app.exit(1)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
main.pipe(Effect.provide(NodeHttpClient.layerFetch), NodeRuntime.runMain())
|
||||||
|
|
||||||
|
const wireMenu = (win: BrowserWindow) => {
|
||||||
|
createMenu({
|
||||||
|
trigger: (id) => {
|
||||||
|
sendMenuCommand(win, id)
|
||||||
|
},
|
||||||
|
checkForUpdates: () => {
|
||||||
|
void checkForUpdates(true)
|
||||||
|
},
|
||||||
|
reload: () => win.reload(),
|
||||||
|
relaunch: () => {
|
||||||
|
void Effect.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* stopServer(server)
|
||||||
|
app.relaunch()
|
||||||
|
app.exit(0)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerIpcHandlersImpl = () =>
|
||||||
|
registerIpcHandlers({
|
||||||
|
killSidecar: () => Effect.runPromise(stopServer(server)),
|
||||||
|
awaitInitialization: (sendStep) =>
|
||||||
|
Effect.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const currentStep = yield* SubscriptionRef.get(initStep)
|
||||||
|
sendStep(currentStep)
|
||||||
|
|
||||||
|
yield* SubscriptionRef.changes(initStep).pipe(
|
||||||
|
Stream.runForEach((step) => Effect.sync(() => sendStep(step))),
|
||||||
|
Effect.forkScoped,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.log("awaiting server ready")
|
||||||
|
const res = yield* Deferred.await(serverReady)
|
||||||
|
logger.log("server ready", { url: res.url })
|
||||||
|
|
||||||
|
return res
|
||||||
|
}).pipe(Effect.scoped),
|
||||||
|
),
|
||||||
|
getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }),
|
||||||
|
consumeInitialDeepLinks: () =>
|
||||||
|
Effect.runPromise(
|
||||||
|
Queue.clear(deepLinkQueue).pipe(
|
||||||
|
Effect.map((links) => links.flat()),
|
||||||
|
Effect.tap(() => Deferred.succeed(deepLinksConsumed, undefined)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
getDefaultServerUrl: () => getDefaultServerUrl(),
|
||||||
|
setDefaultServerUrl: (url) => setDefaultServerUrl(url),
|
||||||
|
getWslConfig: () => Promise.resolve(getWslConfig()),
|
||||||
|
setWslConfig: (config: WslConfig) => setWslConfig(config),
|
||||||
|
getDisplayBackend: () => Promise.resolve(null),
|
||||||
|
setDisplayBackend: () => Promise.resolve(undefined),
|
||||||
|
parseMarkdown: (markdown) => Promise.resolve(parseMarkdown(markdown)),
|
||||||
|
checkAppExists: (appName) => checkAppExists(appName),
|
||||||
|
wslPath: (path, mode) => Promise.resolve(wslPath(path, mode)),
|
||||||
|
resolveAppPath: (appName) => Promise.resolve(resolveAppPath(appName)),
|
||||||
|
loadingWindowComplete: () => Effect.runPromise(Deferred.succeed(loadingComplete, undefined)),
|
||||||
|
runUpdater: (alertOnFail) => checkForUpdates(alertOnFail),
|
||||||
|
checkUpdate: () => checkUpdate(),
|
||||||
|
installUpdate: () => installUpdate(),
|
||||||
|
setBackgroundColor,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getSidecarPort = Effect.gen(function* () {
|
||||||
const fromEnv = process.env.OPENCODE_PORT
|
const fromEnv = process.env.OPENCODE_PORT
|
||||||
if (fromEnv) {
|
if (fromEnv) {
|
||||||
const parsed = Number.parseInt(fromEnv, 10)
|
const parsed = Number.parseInt(fromEnv, 10)
|
||||||
if (!Number.isNaN(parsed)) return parsed
|
if (!Number.isNaN(parsed)) return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
return await new Promise<number>((resolve, reject) => {
|
const deferred = yield* Deferred.make<number, string>()
|
||||||
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 server = createServer()
|
||||||
|
server.on("error", (e) => Deferred.failSync(deferred, () => e.toString()))
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address()
|
||||||
|
if (typeof address !== "object" || !address) {
|
||||||
|
server.close()
|
||||||
|
Deferred.failSync(deferred, () => "Failed to get port")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const port = address.port
|
||||||
|
server.close(() => Effect.runSync(Deferred.succeed(deferred, port)))
|
||||||
|
})
|
||||||
|
|
||||||
|
return yield* Deferred.await(deferred)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sqliteFileExists = () => {
|
||||||
const xdg = process.env.XDG_DATA_HOME
|
const xdg = process.env.XDG_DATA_HOME
|
||||||
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
|
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
|
||||||
return existsSync(join(base, "opencode", "opencode.db"))
|
return existsSync(join(base, "opencode", "opencode.db"))
|
||||||
|
|
@ -393,7 +479,7 @@ async function installUpdate() {
|
||||||
logger.log("installing downloaded update", {
|
logger.log("installing downloaded update", {
|
||||||
version: downloadedUpdateVersion,
|
version: downloadedUpdateVersion,
|
||||||
})
|
})
|
||||||
killSidecar()
|
void Effect.runPromise(stopServer(server))
|
||||||
autoUpdater.quitAndInstall()
|
autoUpdater.quitAndInstall()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -439,17 +525,3 @@ async function checkForUpdates(alertOnFail: boolean) {
|
||||||
await installUpdate()
|
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 }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -196,14 +196,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
|
export const sendSqliteMigrationProgress = (win: BrowserWindow, progress: SqliteMigrationProgress) =>
|
||||||
win.webContents.send("sqlite-migration-progress", progress)
|
win.webContents.send("sqlite-migration-progress", progress)
|
||||||
}
|
export const sendMenuCommand = (win: BrowserWindow, id: string) => win.webContents.send("menu-command", id)
|
||||||
|
export const sendDeepLinks = (win: BrowserWindow, urls: string[]) => win.webContents.send("deep-link", urls)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Menu, shell } from "electron"
|
import { Menu, shell } from "electron"
|
||||||
|
|
||||||
import { UPDATER_ENABLED } from "./constants"
|
import { UPDATER_ENABLED } from "./constants"
|
||||||
import { createMainWindow } from "./windows"
|
import { createMainWindow } from "./windows"
|
||||||
|
|
||||||
|
|
@ -47,7 +46,9 @@ export function createMenu(deps: Deps) {
|
||||||
{
|
{
|
||||||
label: "New Window",
|
label: "New Window",
|
||||||
accelerator: "Cmd+Shift+N",
|
accelerator: "Cmd+Shift+N",
|
||||||
click: () => createMainWindow(),
|
click: () => {
|
||||||
|
void createMainWindow()
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
{ type: "separator" },
|
||||||
{ role: "close" },
|
{ role: "close" },
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,65 @@
|
||||||
import { app } from "electron"
|
import { app } from "electron"
|
||||||
|
import { Effect, Option } from "effect"
|
||||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
|
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
export type WslConfig = { enabled: boolean }
|
export type WslConfig = { enabled: boolean }
|
||||||
|
|
||||||
export type HealthCheck = { wait: Promise<void> }
|
export const getDefaultServerUrl = (): string | null => {
|
||||||
|
|
||||||
export function getDefaultServerUrl(): string | null {
|
|
||||||
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
||||||
return typeof value === "string" ? value : null
|
return typeof value === "string" ? value : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setDefaultServerUrl(url: string | null) {
|
export const setDefaultServerUrl = (url: string | null) => {
|
||||||
if (url) {
|
if (url) {
|
||||||
getStore().set(DEFAULT_SERVER_URL_KEY, url)
|
getStore().set(DEFAULT_SERVER_URL_KEY, url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
getStore().delete(DEFAULT_SERVER_URL_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWslConfig(): WslConfig {
|
export const getWslConfig = (): WslConfig => {
|
||||||
const value = getStore().get(WSL_ENABLED_KEY)
|
const value = getStore().get(WSL_ENABLED_KEY)
|
||||||
return { enabled: typeof value === "boolean" ? value : false }
|
return { enabled: typeof value === "boolean" ? value : false }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setWslConfig(config: WslConfig) {
|
export const setWslConfig = (config: WslConfig) => getStore().set(WSL_ENABLED_KEY, config.enabled)
|
||||||
getStore().set(WSL_ENABLED_KEY, config.enabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function spawnLocalServer(hostname: string, port: number, password: string) {
|
export const spawnLocalServerEffect = Effect.fn("Server.spawnLocalServer")(function* (
|
||||||
|
hostname: string,
|
||||||
|
port: number,
|
||||||
|
password: string,
|
||||||
|
) {
|
||||||
prepareServerEnv(password)
|
prepareServerEnv(password)
|
||||||
const { Log, Server } = await import("virtual:opencode-server")
|
const { Log, Server } = yield* Effect.promise(
|
||||||
await Log.init({ level: "WARN" })
|
() => import("virtual:opencode-server") as Promise<typeof import("virtual:opencode-server")>,
|
||||||
const listener = await Server.listen({
|
)
|
||||||
port,
|
yield* Effect.promise(() => Log.init({ level: "WARN" }))
|
||||||
hostname,
|
const listener = yield* Effect.promise(() =>
|
||||||
username: "opencode",
|
Server.listen({
|
||||||
password,
|
port,
|
||||||
cors: ["oc://renderer"],
|
hostname,
|
||||||
|
username: "opencode",
|
||||||
|
password,
|
||||||
|
cors: ["oc://renderer"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const healthCheck = Effect.gen(function* () {
|
||||||
|
const url = `http://${hostname}:${port}`
|
||||||
|
while (true) {
|
||||||
|
const healthy = yield* checkHealthEffect(url, password)
|
||||||
|
if (healthy) return
|
||||||
|
yield* Effect.sleep("100 millis")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const wait = (async () => {
|
return { listener, health: healthCheck }
|
||||||
const url = `http://${hostname}:${port}`
|
})
|
||||||
|
|
||||||
const ready = async () => {
|
const prepareServerEnv = (password: string) => () => {
|
||||||
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 shell = process.platform === "win32" ? null : getUserShell()
|
||||||
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
|
const shellEnv = shell ? (loadShellEnv(shell) ?? {}) : {}
|
||||||
const env = {
|
const env = {
|
||||||
|
|
@ -74,7 +75,9 @@ function prepareServerEnv(password: string) {
|
||||||
Object.assign(process.env, env)
|
Object.assign(process.env, env)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkHealth(url: string, password?: string | null): Promise<boolean> {
|
export const checkHealthEffect = Effect.fn("Server.checkHealth")(function* (url: string, password?: string | null) {
|
||||||
|
const httpClient = yield* HttpClient.HttpClient
|
||||||
|
|
||||||
let healthUrl: URL
|
let healthUrl: URL
|
||||||
try {
|
try {
|
||||||
healthUrl = new URL("/global/health", url)
|
healthUrl = new URL("/global/health", url)
|
||||||
|
|
@ -88,14 +91,12 @@ export async function checkHealth(url: string, password?: string | null): Promis
|
||||||
headers.set("authorization", `Basic ${auth}`)
|
headers.set("authorization", `Basic ${auth}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return yield* httpClient
|
||||||
const res = await fetch(healthUrl, {
|
.get(healthUrl, { headers })
|
||||||
method: "GET",
|
.pipe(
|
||||||
headers,
|
Effect.timeout("3 seconds"),
|
||||||
signal: AbortSignal.timeout(3000),
|
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
||||||
})
|
Effect.option,
|
||||||
return res.ok
|
Effect.map(Option.isSome),
|
||||||
} catch {
|
)
|
||||||
return false
|
})
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,11 @@ let backgroundColor: string | undefined
|
||||||
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
|
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
|
||||||
const titlebarHeight = 40
|
const titlebarHeight = 40
|
||||||
|
|
||||||
export function setBackgroundColor(color: string) {
|
export const setBackgroundColor = (color: string) => {
|
||||||
backgroundColor = color
|
backgroundColor = color
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBackgroundColor(): string | undefined {
|
export const getBackgroundColor = () => backgroundColor
|
||||||
return backgroundColor
|
|
||||||
}
|
|
||||||
|
|
||||||
function iconsDir() {
|
function iconsDir() {
|
||||||
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
|
return app.isPackaged ? join(process.resourcesPath, "icons") : join(root, "../../resources/icons")
|
||||||
|
|
@ -77,6 +75,7 @@ export function createMainWindow() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const mode = tone()
|
const mode = tone()
|
||||||
|
const bg = getBackgroundColor()
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
x: state.x,
|
x: state.x,
|
||||||
y: state.y,
|
y: state.y,
|
||||||
|
|
@ -85,7 +84,7 @@ export function createMainWindow() {
|
||||||
show: false,
|
show: false,
|
||||||
title: "OpenCode",
|
title: "OpenCode",
|
||||||
icon: iconPath(),
|
icon: iconPath(),
|
||||||
backgroundColor,
|
backgroundColor: bg,
|
||||||
...(process.platform === "darwin"
|
...(process.platform === "darwin"
|
||||||
? {
|
? {
|
||||||
titleBarStyle: "hidden" as const,
|
titleBarStyle: "hidden" as const,
|
||||||
|
|
@ -133,6 +132,7 @@ export function createMainWindow() {
|
||||||
|
|
||||||
export function createLoadingWindow() {
|
export function createLoadingWindow() {
|
||||||
const mode = tone()
|
const mode = tone()
|
||||||
|
const bg = getBackgroundColor()
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
width: 640,
|
width: 640,
|
||||||
height: 480,
|
height: 480,
|
||||||
|
|
@ -140,7 +140,7 @@ export function createLoadingWindow() {
|
||||||
center: true,
|
center: true,
|
||||||
show: true,
|
show: true,
|
||||||
icon: iconPath(),
|
icon: iconPath(),
|
||||||
backgroundColor,
|
backgroundColor: bg,
|
||||||
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
|
||||||
...(process.platform === "win32"
|
...(process.platform === "win32"
|
||||||
? {
|
? {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,11 @@
|
||||||
export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
|
import { Data } from "effect"
|
||||||
|
|
||||||
|
export type InitStep = Data.TaggedEnum<{
|
||||||
|
ServerWaiting: {}
|
||||||
|
SqliteWaiting: {}
|
||||||
|
Done: {}
|
||||||
|
}>
|
||||||
|
export const InitStep = Data.taggedEnum<InitStep>()
|
||||||
|
|
||||||
export type ServerReadyData = {
|
export type ServerReadyData = {
|
||||||
url: string
|
url: string
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,14 @@ render(() => {
|
||||||
const [line, setLine] = createSignal(0)
|
const [line, setLine] = createSignal(0)
|
||||||
const [percent, setPercent] = createSignal(0)
|
const [percent, setPercent] = createSignal(0)
|
||||||
|
|
||||||
const phase = createMemo(() => step()?.phase)
|
const phase = createMemo(() => step()?._tag)
|
||||||
|
|
||||||
const value = createMemo(() => {
|
const value = createMemo(() => {
|
||||||
if (phase() === "done") return 100
|
if (phase() === "Done") return 100
|
||||||
return Math.max(25, Math.min(100, percent()))
|
return Math.max(25, Math.min(100, percent()))
|
||||||
})
|
})
|
||||||
|
|
||||||
window.api.awaitInitialization((next) => setStep(next as InitStep)).catch(() => undefined)
|
window.api.awaitInitialization((next) => setStep(next)).catch(() => undefined)
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setLine(0)
|
setLine(0)
|
||||||
|
|
@ -36,7 +36,7 @@ render(() => {
|
||||||
if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
|
if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
|
||||||
if (progress.type === "Done") {
|
if (progress.type === "Done") {
|
||||||
setPercent(100)
|
setPercent(100)
|
||||||
setStep({ phase: "done" })
|
setStep({ _tag: "Done" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -47,15 +47,15 @@ render(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (phase() !== "done") return
|
if (phase() !== "Done") return
|
||||||
|
|
||||||
const timer = setTimeout(() => window.api.loadingWindowComplete(), 1000)
|
const timer = setTimeout(() => window.api.loadingWindowComplete(), 1000)
|
||||||
onCleanup(() => clearTimeout(timer))
|
onCleanup(() => clearTimeout(timer))
|
||||||
})
|
})
|
||||||
|
|
||||||
const status = createMemo(() => {
|
const status = createMemo(() => {
|
||||||
if (phase() === "done") return "All done"
|
if (phase() === "Done") return "All done"
|
||||||
if (phase() === "sqlite_waiting") return lines[line()]
|
if (phase() === "SqliteWaiting") return lines[line()]
|
||||||
return "Just a moment..."
|
return "Just a moment..."
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue