mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-06 08:56:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a53d9de58f |
@@ -16,6 +16,7 @@ import { LanguageProvider, UiI18nBridge, type Locale } from "@/runtime/i18n/lang
|
||||
import { ServerConnection, ServersProvider } from "@/runtime/server/registry"
|
||||
import { SettingsProvider } from "@/settings/model"
|
||||
import { TabsProvider } from "@/shell/tabs/tabs"
|
||||
import { KeepAwake } from "@/shell/keep-awake"
|
||||
import { WslServersProvider } from "@/servers/wsl/context"
|
||||
import { ErrorPage } from "@/shell/errors/error"
|
||||
import { AppRoutes, File, preloadRoute } from "@/shell/routes/routes"
|
||||
@@ -106,6 +107,7 @@ export function AppInterface(props: {
|
||||
const Root = (rootProps: ParentProps) => (
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<KeepAwake />
|
||||
<BodyTypography />
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
|
||||
@@ -1073,6 +1073,10 @@ export const dict = {
|
||||
"settings.general.row.editToolPartsExpanded.description":
|
||||
"Show edit, write, and patch tool parts expanded by default in the timeline",
|
||||
"settings.general.row.pinchZoom.title": "Pinch to zoom",
|
||||
"settings.general.row.keepAwake.title": "Keep computer awake",
|
||||
"settings.general.row.keepAwake.description":
|
||||
"Prevent sleep while open tabs have running sessions, even in the background. The screen can still turn off.",
|
||||
"settings.general.row.keepAwake.error": "Could not access the keep-awake setting. Try again.",
|
||||
"settings.general.row.pinchZoom.description": "Allow trackpad pinch and Ctrl-scroll gestures to zoom",
|
||||
|
||||
"settings.general.row.wayland.title": "Use native Wayland",
|
||||
|
||||
@@ -98,6 +98,15 @@ type PlatformBase = {
|
||||
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
|
||||
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
|
||||
|
||||
/** Get the application-wide preference for keeping running session tabs awake. */
|
||||
getKeepAwakeEnabled?(this: void): Promise<boolean>
|
||||
|
||||
/** Opt in to preventing idle system sleep while any window has a running session tab. */
|
||||
setKeepAwakeEnabled?(this: void, enabled: boolean): Promise<void>
|
||||
|
||||
/** Report whether this window has running session tabs, independent of the preference. */
|
||||
setKeepAwakeActive?(this: void, active: boolean): Promise<void>
|
||||
|
||||
/** Run a desktop-only menu action from the app chrome */
|
||||
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from "./controllers"
|
||||
import "@/settings/settings.css"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { KeepAwakeSetting } from "./keep-awake"
|
||||
|
||||
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
|
||||
const fontSettings = {
|
||||
@@ -332,6 +333,10 @@ export const SettingsGeneral: Component<{
|
||||
<TerminalPlacementSetting />
|
||||
<FollowUpBehaviorSetting />
|
||||
|
||||
<Show when={desktop()}>
|
||||
<KeepAwakeSetting />
|
||||
</Show>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("session.review.wrapLines")}
|
||||
description={language.t("settings.general.row.mobileDiffWrap.description")}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
|
||||
export function KeepAwakeSetting() {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const get = platform.getKeepAwakeEnabled
|
||||
const set = platform.setKeepAwakeEnabled
|
||||
if (!get || !set) return null
|
||||
|
||||
const [state, setState] = createStore({ saving: false })
|
||||
const failed = () => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: language.t("settings.general.row.keepAwake.error"),
|
||||
})
|
||||
}
|
||||
const [enabled, { mutate, refetch }] = createResource(() => get().catch(failed))
|
||||
makeEventListener(window, "focus", () => {
|
||||
if (!state.saving) void refetch()
|
||||
})
|
||||
|
||||
const change = (checked: boolean) => {
|
||||
const previous = enabled.latest
|
||||
setState("saving", true)
|
||||
mutate(checked)
|
||||
void set(checked)
|
||||
.catch(() => {
|
||||
mutate(previous)
|
||||
failed()
|
||||
})
|
||||
.finally(() => setState("saving", false))
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.keepAwake.title")}
|
||||
description={language.t("settings.general.row.keepAwake.description")}
|
||||
>
|
||||
<div data-action="settings-keep-awake">
|
||||
<Switch
|
||||
aria-label={language.t("settings.general.row.keepAwake.title")}
|
||||
checked={enabled.latest === true}
|
||||
disabled={enabled.loading || enabled.latest === undefined || state.saving}
|
||||
onChange={change}
|
||||
hideLabel
|
||||
>
|
||||
{language.t("settings.general.row.keepAwake.title")}
|
||||
</Switch>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { useTabs, type Tab } from "@/shell/tabs/tabs"
|
||||
|
||||
export function createOpenTabsKeepAwake(input: {
|
||||
tabs: Accessor<readonly Tab[]>
|
||||
running: (server: ServerConnection.Key, sessionID: string) => boolean
|
||||
setActive: (active: boolean) => Promise<void>
|
||||
}) {
|
||||
const active = createMemo(() =>
|
||||
input
|
||||
.tabs()
|
||||
.some(
|
||||
(tab) =>
|
||||
tab.type === "session" &&
|
||||
(input.running(tab.server, tab.sessionId) ||
|
||||
(!!tab.routeSessionId && input.running(tab.server, tab.routeSessionId))),
|
||||
),
|
||||
)
|
||||
const report = (value: boolean) => {
|
||||
void input.setActive(value).catch((error) => console.error("Failed to update desktop keep-awake activity", error))
|
||||
}
|
||||
createEffect(() => report(active()))
|
||||
onCleanup(() => report(false))
|
||||
}
|
||||
|
||||
export function KeepAwake() {
|
||||
const platform = usePlatform()
|
||||
if (!platform.setKeepAwakeActive) return null
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
createOpenTabsKeepAwake({
|
||||
tabs: () => tabs.store,
|
||||
running: (server, sessionID) => {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === server)
|
||||
return !!conn && global.ensureServerCtx(conn).data.session.status(sessionID) === "running"
|
||||
},
|
||||
setActive: platform.setKeepAwakeActive,
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import type { Tab } from "@/shell/tabs/tabs"
|
||||
import { createOpenTabsKeepAwake } from "../src/shell/keep-awake"
|
||||
|
||||
const local = ServerConnection.key({ type: "http", http: { url: "http://localhost:4096" } })
|
||||
const remote = ServerConnection.key({ type: "http", http: { url: "https://example.com" } })
|
||||
const session = (sessionId: string, server = local): Tab => ({ type: "session", server, sessionId })
|
||||
|
||||
function setup(tabs: Tab[] = []) {
|
||||
const calls: boolean[] = []
|
||||
const owned = createRoot((dispose) => {
|
||||
const [store, setStore] = createStore({
|
||||
tabs,
|
||||
running: {} as Record<string, Record<string, boolean>>,
|
||||
})
|
||||
createOpenTabsKeepAwake({
|
||||
tabs: () => store.tabs,
|
||||
running: (server, sessionID) => store.running[server]?.[sessionID] ?? false,
|
||||
setActive: async (active) => {
|
||||
calls.push(active)
|
||||
},
|
||||
})
|
||||
return { dispose, setStore }
|
||||
})
|
||||
return { ...owned, calls }
|
||||
}
|
||||
|
||||
describe("open session tab keep-awake", () => {
|
||||
test("ignores drafts and running sessions without an open tab", () => {
|
||||
const state = setup([{ type: "draft", server: local, draftID: "draft", directory: "/project" }])
|
||||
state.setStore("running", local, { draft: true, closed: true })
|
||||
expect(state.calls).toEqual([false])
|
||||
state.dispose()
|
||||
})
|
||||
|
||||
test("counts every tab, not just the first or selected tab", () => {
|
||||
const state = setup([session("idle"), session("background")])
|
||||
state.setStore("running", local, { background: true })
|
||||
expect(state.calls).toEqual([false, true])
|
||||
state.setStore("tabs", [session("background"), session("idle")])
|
||||
expect(state.calls).toEqual([false, true])
|
||||
state.dispose()
|
||||
expect(state.calls).toEqual([false, true, false])
|
||||
})
|
||||
|
||||
test("closing the last running tab releases without stopping its execution", () => {
|
||||
const state = setup([session("a"), session("b")])
|
||||
state.setStore("running", local, { a: true, b: true })
|
||||
state.setStore("tabs", [session("b")])
|
||||
expect(state.calls).toEqual([false, true])
|
||||
state.setStore("tabs", [])
|
||||
expect(state.calls).toEqual([false, true, false])
|
||||
state.setStore("tabs", [session("a")])
|
||||
expect(state.calls).toEqual([false, true, false, true])
|
||||
state.dispose()
|
||||
})
|
||||
|
||||
test("releases when the last session finishes and follows refreshed activity", () => {
|
||||
const state = setup([session("a"), session("b")])
|
||||
state.setStore("running", local, { a: true, b: true })
|
||||
state.setStore("running", local, "a", false)
|
||||
expect(state.calls).toEqual([false, true])
|
||||
state.setStore("running", local, "b", false)
|
||||
expect(state.calls).toEqual([false, true, false])
|
||||
state.setStore("running", local, "b", true)
|
||||
expect(state.calls).toEqual([false, true, false, true])
|
||||
state.dispose()
|
||||
})
|
||||
|
||||
test("scopes session identity to its server", () => {
|
||||
const state = setup([session("same", remote)])
|
||||
state.setStore("running", local, { same: true })
|
||||
expect(state.calls).toEqual([false])
|
||||
state.setStore("running", remote, { same: true })
|
||||
expect(state.calls).toEqual([false, true])
|
||||
state.dispose()
|
||||
})
|
||||
|
||||
test("counts the child session currently represented by a root tab", () => {
|
||||
const state = setup([{ type: "session", server: local, sessionId: "root", routeSessionId: "child" }])
|
||||
state.setStore("running", local, { child: true })
|
||||
expect(state.calls).toEqual([false, true])
|
||||
state.setStore("tabs", [session("root")])
|
||||
expect(state.calls).toEqual([false, true, false])
|
||||
state.dispose()
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { DesktopLogging, scoped } from "../native/logging"
|
||||
import { createMenu, sendMenuCommand } from "../native/menu"
|
||||
import { setNativeTranslations } from "../native/translations"
|
||||
import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { KeepAwake } from "../keep-awake"
|
||||
import { ApplicationLifecycle } from "../lifecycle"
|
||||
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
|
||||
import { BackgroundService } from "../service/background-service"
|
||||
@@ -27,6 +28,7 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
const desktopCli = yield* DesktopCli.Service
|
||||
const updater = yield* Updater.Service
|
||||
const logging = yield* DesktopLogging.Service
|
||||
const awake = yield* KeepAwake.Service
|
||||
const runFork = Effect.runForkWith(yield* Effect.context())
|
||||
return AppRpcs.of({
|
||||
AppAwaitInitialization: () => background.connection,
|
||||
@@ -34,6 +36,17 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks),
|
||||
AppGetDefaultServerUrl: () => Effect.sync(getDefaultServerUrl),
|
||||
AppSetDefaultServerUrl: ({ url }) => Effect.sync(() => setDefaultServerUrl(url)),
|
||||
AppGetKeepAwakeEnabled: () => Effect.sync(awake.getEnabled),
|
||||
AppSetKeepAwakeEnabled: ({ enabled }) => Effect.sync(() => awake.setEnabled(enabled)),
|
||||
AppSetKeepAwakeActive: ({ active }, context) =>
|
||||
Effect.sync(() => {
|
||||
const contents = sender(handoff, context)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents) {
|
||||
throw new Error("Invalid keep-awake sender")
|
||||
}
|
||||
awake.setActive(contents, active)
|
||||
}),
|
||||
AppIsFirstLaunchOnboardingPending: isFirstLaunchOnboardingPending,
|
||||
AppFinishFirstLaunchOnboarding: ({ createDefaultProject }) =>
|
||||
finishFirstLaunchOnboarding(createDefaultProject).pipe(Effect.orDie),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { updaterHandlers } from "./ipc-handlers/updater"
|
||||
import { windowHandlers } from "./ipc-handlers/window"
|
||||
import { wslHandlers } from "./ipc-handlers/wsl"
|
||||
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
|
||||
import { KeepAwakeLive } from "./keep-awake/live"
|
||||
import { ApplicationLifecycle } from "./lifecycle"
|
||||
import { showCliInstaller } from "./native/install-cli"
|
||||
import { createMenu, sendMenuCommand } from "./native/menu"
|
||||
@@ -24,7 +25,7 @@ import { Updater } from "./updater"
|
||||
import { getLastFocusedWindow } from "./windows"
|
||||
import { Wsl } from "./wsl/start"
|
||||
|
||||
const services = Layer.mergeAll(DesktopFiles.layer, DesktopStorage.layer, Wsl.layer)
|
||||
const services = Layer.mergeAll(DesktopFiles.layer, DesktopStorage.layer, Wsl.layer, KeepAwakeLive)
|
||||
const handlers = Layer.mergeAll(
|
||||
appHandlers,
|
||||
storageHandlers,
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { Layer, ManagedRuntime } from "effect"
|
||||
import { KeepAwake } from "./index"
|
||||
|
||||
const disposers: Array<() => Promise<void>> = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(disposers.splice(0).map((dispose) => dispose()))
|
||||
})
|
||||
|
||||
async function setup(initial?: unknown) {
|
||||
const starts: string[] = []
|
||||
const stops: number[] = []
|
||||
const writes: boolean[] = []
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.effect(
|
||||
KeepAwake.Service,
|
||||
KeepAwake.make({
|
||||
power: {
|
||||
start: (type) => starts.push(type) - 1,
|
||||
stop: (id) => {
|
||||
stops.push(id)
|
||||
return true
|
||||
},
|
||||
},
|
||||
persistence: { read: () => initial, write: (value) => void writes.push(value) },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const dispose = () => runtime.dispose()
|
||||
disposers.push(dispose)
|
||||
return { awake: await runtime.runPromise(KeepAwake.Service), starts, stops, writes, dispose }
|
||||
}
|
||||
|
||||
class Sender extends EventEmitter {
|
||||
destroyed = false
|
||||
constructor(readonly id: number) {
|
||||
super()
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed
|
||||
}
|
||||
}
|
||||
|
||||
describe("desktop keep awake", () => {
|
||||
test.each([undefined, false, null, "true", 1])("defaults off without an explicit opt-in: %p", async (initial) => {
|
||||
const { awake, starts, writes } = await setup(initial)
|
||||
awake.setActive(new Sender(1), true)
|
||||
expect(awake.getEnabled()).toBe(false)
|
||||
expect(starts).toEqual([])
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
test("applies a persisted opt-in only after an open tab reports activity", async () => {
|
||||
const { awake, starts, writes } = await setup(true)
|
||||
expect(awake.getEnabled()).toBe(true)
|
||||
expect(starts).toEqual([])
|
||||
awake.setActive(new Sender(1), true)
|
||||
expect(starts).toEqual(["prevent-app-suspension"])
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
test("enabling uses reports received while disabled, and disabling preserves them", async () => {
|
||||
const { awake, starts, stops, writes } = await setup()
|
||||
awake.setActive(new Sender(1), true)
|
||||
awake.setEnabled(true)
|
||||
expect(starts).toEqual(["prevent-app-suspension"])
|
||||
awake.setEnabled(false)
|
||||
expect(stops).toEqual([0])
|
||||
awake.setEnabled(true)
|
||||
expect(starts).toEqual(["prevent-app-suspension", "prevent-app-suspension"])
|
||||
expect(writes).toEqual([true, false, true])
|
||||
})
|
||||
|
||||
test("releases when the last running tab closes, without waiting for the session", async () => {
|
||||
const { awake, starts, stops } = await setup(true)
|
||||
const sender = new Sender(1)
|
||||
awake.setActive(sender, true)
|
||||
awake.setActive(sender, false)
|
||||
expect(stops).toEqual([0])
|
||||
expect(sender.eventNames()).toEqual([])
|
||||
awake.setActive(sender, true)
|
||||
expect(starts).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("ORs activity across windows, independent of focus or visibility", async () => {
|
||||
const { awake, starts, stops } = await setup(true)
|
||||
const first = new Sender(1)
|
||||
const second = new Sender(2)
|
||||
awake.setActive(first, true)
|
||||
awake.setActive(second, false)
|
||||
awake.setActive(second, true)
|
||||
awake.setActive(first, false)
|
||||
expect(starts).toHaveLength(1)
|
||||
expect(stops).toEqual([])
|
||||
second.emit("destroyed")
|
||||
expect(stops).toEqual([0])
|
||||
})
|
||||
|
||||
test("repeated reports and settings do not duplicate blockers or listeners", async () => {
|
||||
const { awake, starts, stops, writes } = await setup()
|
||||
const sender = new Sender(1)
|
||||
for (let i = 0; i < 100; i++) {
|
||||
awake.setEnabled(true)
|
||||
awake.setActive(sender, true)
|
||||
}
|
||||
expect(starts).toHaveLength(1)
|
||||
expect(writes).toEqual([true])
|
||||
expect(sender.listenerCount("destroyed")).toBe(1)
|
||||
expect(sender.listenerCount("render-process-gone")).toBe(1)
|
||||
expect(sender.listenerCount("did-start-navigation")).toBe(1)
|
||||
awake.setActive(sender, false)
|
||||
awake.setActive(sender, false)
|
||||
expect(stops).toEqual([0])
|
||||
expect(sender.eventNames()).toEqual([])
|
||||
})
|
||||
|
||||
test.each(["destroyed", "render-process-gone"])("clears activity on %s", async (event) => {
|
||||
const { awake, stops } = await setup(true)
|
||||
const sender = new Sender(1)
|
||||
awake.setActive(sender, true)
|
||||
sender.emit(event)
|
||||
expect(stops).toEqual([0])
|
||||
expect(sender.eventNames()).toEqual([])
|
||||
})
|
||||
|
||||
test("clears on main-frame reload/navigation, not app routes or subframes", async () => {
|
||||
const { awake, stops, starts } = await setup(true)
|
||||
const sender = new Sender(1)
|
||||
awake.setActive(sender, true)
|
||||
sender.emit("did-start-navigation", {}, "app://index/session", true, true)
|
||||
sender.emit("did-start-navigation", {}, "app://index/frame", false, false)
|
||||
expect(stops).toEqual([])
|
||||
sender.emit("did-start-navigation", {}, "app://index", false, true)
|
||||
expect(stops).toEqual([0])
|
||||
expect(sender.eventNames()).toEqual([])
|
||||
awake.setActive(sender, true)
|
||||
expect(starts).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("forgets disabled activity when its renderer closes", async () => {
|
||||
const { awake, starts } = await setup()
|
||||
const sender = new Sender(1)
|
||||
awake.setActive(sender, true)
|
||||
sender.emit("destroyed")
|
||||
awake.setEnabled(true)
|
||||
expect(starts).toEqual([])
|
||||
})
|
||||
|
||||
test("ignores destroyed renderers", async () => {
|
||||
const { awake, starts } = await setup(true)
|
||||
const sender = new Sender(1)
|
||||
sender.destroyed = true
|
||||
awake.setActive(sender, true)
|
||||
expect(starts).toEqual([])
|
||||
expect(sender.eventNames()).toEqual([])
|
||||
})
|
||||
|
||||
test("scope disposal releases the blocker and all listeners", async () => {
|
||||
const { awake, starts, stops, dispose } = await setup(true)
|
||||
const first = new Sender(1)
|
||||
const second = new Sender(2)
|
||||
awake.setActive(first, true)
|
||||
awake.setActive(second, true)
|
||||
await dispose()
|
||||
awake.dispose()
|
||||
awake.setActive(first, true)
|
||||
awake.setEnabled(false)
|
||||
awake.setEnabled(true)
|
||||
expect(starts).toHaveLength(1)
|
||||
expect(stops).toEqual([0])
|
||||
expect(first.eventNames()).toEqual([])
|
||||
expect(second.eventNames()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
export * as KeepAwake from "./index"
|
||||
|
||||
import type { PowerSaveBlocker, WebContents } from "electron"
|
||||
import { Context, Effect } from "effect"
|
||||
|
||||
type Sender = Pick<WebContents, "id" | "isDestroyed" | "on" | "off">
|
||||
type Dependencies = {
|
||||
power: Pick<PowerSaveBlocker, "start" | "stop">
|
||||
persistence: { read: () => unknown; write: (enabled: boolean) => void }
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Effect.Success<ReturnType<typeof make>>>()(
|
||||
"opencode/desktop/KeepAwake",
|
||||
) {}
|
||||
|
||||
export const make = Effect.fn("KeepAwake.make")(function* (dependencies: Dependencies) {
|
||||
let enabled = dependencies.persistence.read() === true
|
||||
let blocker: number | undefined
|
||||
let disposed = false
|
||||
const senders = new Map<number, () => void>()
|
||||
const update = () => {
|
||||
if (enabled && senders.size > 0) {
|
||||
blocker ??= dependencies.power.start("prevent-app-suspension")
|
||||
return
|
||||
}
|
||||
if (blocker === undefined) return
|
||||
dependencies.power.stop(blocker)
|
||||
blocker = undefined
|
||||
}
|
||||
const remove = (id: number) => {
|
||||
senders.get(id)?.()
|
||||
senders.delete(id)
|
||||
update()
|
||||
}
|
||||
const dispose = () => {
|
||||
disposed = true
|
||||
senders.forEach((off) => off())
|
||||
senders.clear()
|
||||
update()
|
||||
}
|
||||
yield* Effect.addFinalizer(() => Effect.sync(dispose))
|
||||
|
||||
return {
|
||||
getEnabled: () => enabled,
|
||||
setEnabled(value: boolean) {
|
||||
if (disposed || enabled === value) return
|
||||
dependencies.persistence.write(value)
|
||||
enabled = value
|
||||
update()
|
||||
},
|
||||
setActive(sender: Sender, active: boolean) {
|
||||
if (disposed) return
|
||||
if (!active) return remove(sender.id)
|
||||
if (sender.isDestroyed() || senders.has(sender.id)) return
|
||||
const clear = () => remove(sender.id)
|
||||
const navigate = (_event: Electron.Event, _url: string, inPlace: boolean, mainFrame: boolean) => {
|
||||
// App route changes keep the same renderer and its open tabs.
|
||||
if (mainFrame && !inPlace) clear()
|
||||
}
|
||||
sender.on("destroyed", clear)
|
||||
sender.on("render-process-gone", clear)
|
||||
sender.on("did-start-navigation", navigate)
|
||||
senders.set(sender.id, () => {
|
||||
sender.off("destroyed", clear)
|
||||
sender.off("render-process-gone", clear)
|
||||
sender.off("did-start-navigation", navigate)
|
||||
})
|
||||
// Retain reports while disabled so enabling applies to every window immediately.
|
||||
update()
|
||||
},
|
||||
dispose,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { app, powerSaveBlocker } from "electron"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { KEEP_AWAKE_ENABLED_KEY } from "../storage/keys"
|
||||
import { getStore } from "../storage/store"
|
||||
import { KeepAwake } from "./index"
|
||||
|
||||
export const KeepAwakeLive = Layer.effect(
|
||||
KeepAwake.Service,
|
||||
Effect.gen(function* () {
|
||||
const store = getStore()
|
||||
const awake = yield* KeepAwake.make({
|
||||
power: powerSaveBlocker,
|
||||
persistence: {
|
||||
read: () => store.get(KEEP_AWAKE_ENABLED_KEY),
|
||||
write: (enabled) => store.set(KEEP_AWAKE_ENABLED_KEY, enabled),
|
||||
},
|
||||
})
|
||||
app.on("will-quit", awake.dispose)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("will-quit", awake.dispose)))
|
||||
return awake
|
||||
}),
|
||||
)
|
||||
@@ -3,5 +3,6 @@ export const DEFAULT_SERVER_URL_KEY = "defaultServerUrl"
|
||||
export const FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY = "firstLaunchOnboardingComplete"
|
||||
export const WSL_SERVERS_KEY = "wslServers"
|
||||
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
|
||||
export const KEEP_AWAKE_ENABLED_KEY = "keepAwakeEnabled"
|
||||
export const BACKGROUND_COLOR_KEY = "backgroundColor"
|
||||
export const WINDOW_IDS_KEY = "windowIds"
|
||||
|
||||
@@ -28,6 +28,9 @@ export type ElectronAPI = {
|
||||
consumeInitialDeepLinks(): Promise<string[]>
|
||||
getDefaultServerUrl(): Promise<string | null>
|
||||
setDefaultServerUrl(url: string | null): Promise<void>
|
||||
getKeepAwakeEnabled(): Promise<boolean>
|
||||
setKeepAwakeEnabled(enabled: boolean): Promise<void>
|
||||
setKeepAwakeActive(active: boolean): Promise<void>
|
||||
isFirstLaunchOnboardingPending(): Promise<boolean>
|
||||
finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise<string | null>
|
||||
checkAppExists(appName: string): Promise<boolean>
|
||||
|
||||
@@ -70,6 +70,9 @@ export const api: ElectronAPI = {
|
||||
consumeInitialDeepLinks: () => invoke("AppConsumeInitialDeepLinks").then(mutable),
|
||||
getDefaultServerUrl: () => invoke("AppGetDefaultServerUrl"),
|
||||
setDefaultServerUrl: (url) => invoke("AppSetDefaultServerUrl", { url }),
|
||||
getKeepAwakeEnabled: () => invoke("AppGetKeepAwakeEnabled"),
|
||||
setKeepAwakeEnabled: (enabled) => invoke("AppSetKeepAwakeEnabled", { enabled }),
|
||||
setKeepAwakeActive: (active) => invoke("AppSetKeepAwakeActive", { active }),
|
||||
isFirstLaunchOnboardingPending: () => invoke("AppIsFirstLaunchOnboardingPending"),
|
||||
finishFirstLaunchOnboarding: (createDefaultProject) =>
|
||||
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
|
||||
|
||||
@@ -34,6 +34,9 @@ export function createDesktopPlatform(
|
||||
updater,
|
||||
exportDebugLogs: () => api.exportDebugLogs(),
|
||||
setForceFocus: (enabled) => api.setForceFocus(enabled),
|
||||
getKeepAwakeEnabled: () => api.getKeepAwakeEnabled(),
|
||||
setKeepAwakeEnabled: (enabled) => api.setKeepAwakeEnabled(enabled),
|
||||
setKeepAwakeActive: (active) => api.setKeepAwakeActive(active),
|
||||
recordFatalRendererError: (error) => api.recordFatalRendererError(error),
|
||||
restart: async () => api.relaunch(),
|
||||
notify: createDesktopNotify(api),
|
||||
|
||||
@@ -17,6 +17,13 @@ export const AppGetDefaultServerUrl = Rpc.make("AppGetDefaultServerUrl", {
|
||||
export const AppSetDefaultServerUrl = Rpc.make("AppSetDefaultServerUrl", {
|
||||
payload: { url: Schema.NullOr(Schema.String) },
|
||||
})
|
||||
export const AppGetKeepAwakeEnabled = Rpc.make("AppGetKeepAwakeEnabled", { success: Schema.Boolean })
|
||||
export const AppSetKeepAwakeEnabled = Rpc.make("AppSetKeepAwakeEnabled", {
|
||||
payload: { enabled: Schema.Boolean },
|
||||
})
|
||||
export const AppSetKeepAwakeActive = Rpc.make("AppSetKeepAwakeActive", {
|
||||
payload: { active: Schema.Boolean },
|
||||
})
|
||||
export const AppIsFirstLaunchOnboardingPending = Rpc.make("AppIsFirstLaunchOnboardingPending", {
|
||||
success: Schema.Boolean,
|
||||
})
|
||||
@@ -60,6 +67,9 @@ export const AppRpcs = RpcGroup.make(
|
||||
AppConsumeInitialDeepLinks,
|
||||
AppGetDefaultServerUrl,
|
||||
AppSetDefaultServerUrl,
|
||||
AppGetKeepAwakeEnabled,
|
||||
AppSetKeepAwakeEnabled,
|
||||
AppSetKeepAwakeActive,
|
||||
AppIsFirstLaunchOnboardingPending,
|
||||
AppFinishFirstLaunchOnboarding,
|
||||
AppCheckAppExists,
|
||||
|
||||
Reference in New Issue
Block a user