Compare commits

..
100 changed files with 4942 additions and 3154 deletions
+1096 -570
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JEqi00PCle+o5OfBlJJaZtXd+4sYB3o+rvYiESlN4dY=",
"aarch64-linux": "sha256-zk3Uk1SQyeRrQ7BuFwlOnQAptUHIkq+oPdfd+sTEq5U=",
"aarch64-darwin": "sha256-3BOd3EcqimoG3rTI6lTHe91YVlCoEi8/68eT1lbOi0c=",
"x86_64-darwin": "sha256-X7wGmjiMloF5Zhuc20kAxLC+tl613YNXRgA+dQjP2WM="
"x86_64-linux": "sha256-PuNZrtSgh5F3KpXSM+bd+rYQuyzwWd+wCOnMJSDS2Z0=",
"aarch64-linux": "sha256-RYy8ZRf59FE/3+gICjvsZv3ekQvn+DTZaT9jefbK+0g=",
"aarch64-darwin": "sha256-1AsDK8xNj3RlzX2efbuEDEwaOLAgjFYaEvk7EkQkh4w=",
"x86_64-darwin": "sha256-8ONeOu9UmM0GRxVeOO3Uhk1yAOuW6R8tqBYswOVEkME="
}
}
@@ -234,7 +234,7 @@ export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesB
const AnthropicUsage = Schema.StructWithRest(
Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens: optionalNull(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number),
@@ -692,7 +692,7 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
// expose that subset through `output_tokens_details.thinking_tokens`.
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
if (!usage) return undefined
const nonCached = usage.input_tokens
const nonCached = usage.input_tokens ?? undefined
const cacheRead = usage.cache_read_input_tokens ?? undefined
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
@@ -640,7 +640,7 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
it.effect("maps nullable input tokens and preserves unknown Anthropic usage fields", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -663,6 +663,7 @@ describe("Anthropic Messages route", () => {
type: "message_delta",
delta: { stop_reason: "end_turn" },
usage: {
input_tokens: null,
output_tokens: 8,
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
output_tokens_details: { terminal_detail: "preserved" },
@@ -682,7 +683,7 @@ describe("Anthropic Messages route", () => {
totalTokens: 15,
providerMetadata: {
anthropic: {
input_tokens: 5,
input_tokens: null,
cache_read_input_tokens: 2,
service_tier: "standard",
cache_creation: { ephemeral_5m_input_tokens: 1 },
+15 -7
View File
@@ -9,6 +9,10 @@ import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { which } from "./util/which.js"
const resolvedGit = process.platform === "win32" ? which("git") : undefined
const gitExecutable = resolvedGit ? path.resolve(resolvedGit) : "git"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -314,7 +318,7 @@ const layer = Layer.effect(
) {
const result = yield* proc
.run(
ChildProcess.make("git", repositoryArgs(repository, args), {
ChildProcess.make(gitExecutable, repositoryArgs(repository, args), {
cwd: repository.worktree,
env: options?.env,
extendEnv: true,
@@ -485,10 +489,14 @@ const layer = Layer.effect(
if (!input.paths.length) return new Set<RelativePath>()
const result = yield* proc
.run(
ChildProcess.make("git", repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), {
cwd: input.repository.worktree,
extendEnv: true,
}),
ChildProcess.make(
gitExecutable,
repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]),
{
cwd: input.repository.worktree,
extendEnv: true,
},
),
{ stdin: input.paths.join("\0") + "\0" },
)
.pipe(
@@ -662,7 +670,7 @@ const layer = Layer.effect(
cwd = repository.worktree,
) {
const result = yield* proc
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
.run(ChildProcess.make(gitExecutable, args, { cwd, extendEnv: true, stdin: "ignore" }))
.pipe(
Effect.mapError(
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
@@ -759,7 +767,7 @@ function execute(cwd: string, proc: AppProcess.Interface) {
return (args: string[]) =>
proc
.run(
ChildProcess.make("git", args, {
ChildProcess.make(gitExecutable, args, {
cwd,
extendEnv: true,
stdin: "ignore",
+1 -3
View File
@@ -5,7 +5,6 @@ import { Effect } from "effect"
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
import PROMPT_CODEX from "./system-prompt/codex.txt"
import PROMPT_GEMINI from "./system-prompt/gemini.txt"
import PROMPT_GPT from "./system-prompt/gpt.txt"
import PROMPT_KIMI from "./system-prompt/kimi.txt"
import PROMPT_META from "./system-prompt/meta.txt"
@@ -19,7 +18,6 @@ export const OpenAIPlugin = make("openai", (id) => {
if (id.includes("o1") || id.includes("o3")) return PROMPT_GPT
})
export const GooglePlugin = make("google", (id) => (id.includes("gemini-") ? PROMPT_GEMINI : undefined))
export const AnthropicPlugin = make("anthropic", (id) => (id.includes("claude") ? PROMPT_ANTHROPIC : undefined))
export const KimiPlugin = make("kimi", (id) => (id.includes("kimi") ? PROMPT_KIMI : undefined))
export const ArceePlugin = make("arcee", (id) => (id.includes("trinity") ? PROMPT_TRINITY : undefined))
@@ -29,7 +27,7 @@ export const MetaPlugin = make("meta", (id) => {
return PROMPT_META.replaceAll("{{MODEL_NAME}}", name)
})
export const Plugins = [OpenAIPlugin, GooglePlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
function make(id: string, select: (modelID: string) => string | undefined) {
return define({
@@ -1,155 +0,0 @@
You are opencode, an interactive CLI agent specializing in software engineering tasks. Your primary goal is to help users safely and efficiently, adhering strictly to the following instructions and utilizing your available tools.
# Core Mandates
- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first.
- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it.
- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project.
- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically.
- **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. *NEVER* talk to the user or describe your changes through comments.
- **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions.
- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it.
- **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked.
- **Path Construction:** Use the `path` argument for file system tools such as 'read' and 'write'. Relative paths resolve within the working directory.
- **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes.
# Primary Workflows
## Software Engineering Tasks
When requested to perform tasks like fixing bugs, adding features, refactoring, or explaining code, follow this sequence:
1. **Understand:** Think about the user's request and the relevant codebase context. Use 'grep' and 'glob' search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use 'read' to understand context and validate any assumptions you may have.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process. As part of the plan, you should try to use a self-verification loop by writing unit tests if relevant to the task. Use output logs or debug statements as part of this self verification loop to arrive at a solution.
3. **Implement:** Use the available tools (e.g., 'edit', 'write' 'shell' ...) to act on the plan, strictly adhering to the project's established conventions (detailed under 'Core Mandates').
4. **Verify (Tests):** If applicable and feasible, verify the changes using the project's testing procedures. Identify the correct test commands and frameworks by examining 'README' files, build/package configuration (e.g., 'package.json'), or existing test execution patterns. NEVER assume standard test commands.
5. **Verify (Standards):** VERY IMPORTANT: After making code changes, execute the project-specific build, linting and type-checking commands (e.g., 'tsc', 'npm run lint', 'ruff check .') that you have identified for this project (or obtained from the user). This ensures code quality and adherence to standards. If unsure about these commands, you can ask the user if they'd like you to run them and if so how to.
## New Applications
**Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write', 'edit', and 'shell'.
1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions.
2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner.
3. **User Approval:** Obtain user approval for the proposed plan.
4. **Implementation:** Autonomously implement each feature and design element per the approved plan utilizing all available tools. When starting ensure you scaffold the application using the 'shell' tool for commands like 'npm init', 'npx create-react-app'. Aim for full scope completion. Proactively create or source necessary placeholder assets (e.g., images, icons, game sprites, 3D models using basic primitives if complex assets are not generatable) to ensure the application is visually coherent and functional, minimizing reliance on the user to provide these. If the model can generate simple assets (e.g., a uniformly colored square sprite, a simple 3D cube), it should do so. Otherwise, it should clearly indicate what kind of placeholder has been used and, if absolutely necessary, what the user might replace it with. Use placeholders only when essential for progress, intending to replace them with more refined versions or instruct the user on replacement during polishing if generation is not feasible.
5. **Verify:** Review work against the original request, the approved plan. Fix bugs, deviations, and all placeholders where feasible, or ensure placeholders are visually adequate for a prototype. Ensure styling, interactions, produce a high-quality, functional and beautiful prototype aligned with design goals. Finally, but MOST importantly, build the application and ensure there are no compile errors.
6. **Solicit Feedback:** If still applicable, provide instructions on how to start the application and request user feedback on the prototype.
# Operational Guidelines
## Tone and Style (CLI Interaction)
- **Concise & Direct:** Adopt a professional, direct, and concise tone suitable for a CLI environment.
- **Minimal Output:** Aim for fewer than 3 lines of text output (excluding tool use/code generation) per response whenever practical. Focus strictly on the user's query.
- **Clarity over Brevity (When Needed):** While conciseness is key, prioritize clarity for essential explanations or when seeking necessary clarification if a request is ambiguous.
- **No Chitchat:** Avoid conversational filler, preambles ("Okay, I will now..."), or postambles ("I have finished the changes..."). Get straight to the action or answer.
- **Formatting:** Use GitHub-flavored Markdown. Responses will be rendered in monospace.
- **Tools vs. Text:** Use tools for actions, text output *only* for communication. Do not add explanatory comments within tool calls or code blocks unless specifically part of the required code/command itself.
- **Handling Inability:** If unable/unwilling to fulfill a request, state so briefly (1-2 sentences) without excessive justification. Offer alternatives if appropriate.
## Security and Safety Rules
- **Explain Critical Commands:** Before executing commands with the 'shell' tool that modify the file system, codebase, or system state, you *must* provide a brief explanation of the command's purpose and potential impact. Prioritize user understanding and safety. You should not ask permission to use the tool; the user will be presented with a confirmation dialogue upon use (you do not need to tell them this).
- **Security First:** Always apply security best practices. Never introduce code that exposes, logs, or commits secrets, API keys, or other sensitive information.
## Tool Usage
- **File Paths:** Use the `path` argument when referring to files with tools like 'read' or 'write'. Relative paths resolve within the working directory.
- **Parallelism:** Execute multiple independent tool calls in parallel when feasible (i.e. searching the codebase).
- **Command Execution:** Use the 'shell' tool for running shell commands, remembering the safety rule to explain modifying commands first.
- **Background Processes:** Set the shell tool's `background` argument to true for commands that are unlikely to stop on their own, e.g. `node server.js`. If unsure, ask the user.
- **Interactive Commands:** Try to avoid shell commands that are likely to require user interaction (e.g. \`git rebase -i\`). Use non-interactive versions of commands (e.g. \`npm init -y\` instead of \`npm init\`) when available, and otherwise remind the user that interactive shell commands are not supported and may cause hangs until canceled by the user.
- **Respect User Confirmations:** Most tool calls (also denoted as 'function calls') will first require confirmation from the user, where they will either approve or cancel the function call. If a user cancels a function call, respect their choice and do _not_ try to make the function call again. It is okay to request the tool call again _only_ if the user requests that same tool call on a subsequent prompt. When a user cancels a function call, assume best intentions from the user and consider inquiring if they prefer any alternative paths forward.
## Interaction Details
- **Help Command:** The user can use '/help' to display help information.
- **Feedback:** To report a bug or provide feedback, use https://github.com/anomalyco/opencode/issues.
# Examples (Illustrating Tone and Workflow)
<example>
user: 1 + 2
model: 3
</example>
<example>
user: is 13 a prime number?
model: true
</example>
<example>
user: list files here.
model: [tool_call: read for path '/path/to/project']
</example>
<example>
user: start the server implemented in server.js
model: [tool_call: shell for 'node server.js' with background true because it must run in the background]
</example>
<example>
user: Refactor the auth logic in src/auth.py to use the requests library instead of urllib.
model: Okay, I can refactor 'src/auth.py'.
First, I'll analyze the code and check for a test safety net before planning any changes.
[tool_call: glob for pattern 'tests/test_auth.py']
[tool_call: read for path '/path/to/tests/test_auth.py']
(After analysis)
Great, 'tests/test_auth.py' exists and covers the core authentication logic. With this safety net in place, I can safely plan the refactoring.
I'll also confirm 'requests' is a dependency.
[tool_call: read for path '/path/to/requirements.txt']
(After analysis)
Looks good, 'requests' is available.
Here's the plan:
1. Replace the 'urllib' calls with 'requests'.
2. Add proper 'try...except' error handling for the new network calls.
3. Remove the old 'urllib' import.
4. Run the project's linter and tests to verify the changes.
Should I proceed?
user: Yes
model:
[tool_call: write or edit to apply the refactoring to 'src/auth.py']
Refactoring complete. Running verification...
[tool_call: shell for 'ruff check src/auth.py && pytest']
(After verification passes)
All checks passed. This is a stable checkpoint.
<example>
user: Delete the temp directory.
model: I can run \`rm -rf /path/to/project/temp\`. This will permanently delete the directory and all its contents.
</example>
<example>
user: Write tests for someFile.ts
model:
Okay, I can write those tests. First, I'll read \`someFile.ts\` to understand its functionality.
[tool_call: read for path '/path/to/someFile.ts' or use glob to find \`someFile.ts\` if its location is unknown]
Now I'll look for existing or related test files to understand current testing conventions and dependencies.
[tool_call: glob for pattern '**/*.test.ts' and glob for pattern 'src/**/*.spec.ts' assuming someFile.ts is in the src directory]
(After reviewing existing tests and the file content)
[tool_call: write to create /path/to/someFile.test.ts with the test code]
I've written the tests. Now I'll run the project's test command to verify them.
[tool_call: shell for 'npm run test']
</example>
<example>
user: How do I update the user's profile information in this system?
model:
I'm not immediately sure how user profile information is updated. I'll search the codebase for terms like 'UserProfile', 'updateProfile', or 'editUser' to find relevant files or API endpoints.
[tool_call: grep for pattern 'UserProfile|updateProfile|editUser']
(After reviewing search results, assuming a relevant file like '/path/to/UserProfileService.java' was found)
Okay, \`/path/to/UserProfileService.java\` seems like the most relevant file. I'll read its content to understand how updates are handled.
[tool_call: read for path '/path/to/UserProfileService.java']
(After reading the file)
It appears the \`updateUserProfile\` method in \`UserProfileService.java\` is responsible for this. It expects a user ID and a \`UserProfileDTO\` object...
</example>
<example>
user: Where are all the 'app.config' files in this project? I need to check their settings.
model:
[tool_call: glob for pattern '**/app.config']
(Assuming GlobTool returns a list of paths like ['/path/to/moduleA/app.config', '/path/to/moduleB/app.config'])
I found the following 'app.config' files:
- /path/to/moduleA/app.config
- /path/to/moduleB/app.config
To help you check their settings, I can read their contents. Which one would you like to start with, or should I read all of them?
</example>
# Final Reminder
Your core function is efficient and safe assistance. Balance extreme conciseness with the crucial need for clarity, especially regarding safety and potential system modifications. Always prioritize user control and project conventions. Never make assumptions about the contents of files; instead use 'read' to ensure you aren't making broad assumptions. Finally, you are an agent - please keep going until the user's query is completely resolved.
@@ -49,7 +49,6 @@ describe("SystemPromptPlugin", () => {
test("uses granular IDs with a common prefix", () => {
expect(SystemPromptPlugin.Plugins.map((plugin) => plugin.id)).toEqual([
"opencode.prompt.openai",
"opencode.prompt.google",
"opencode.prompt.anthropic",
"opencode.prompt.kimi",
"opencode.prompt.arcee",
@@ -69,7 +68,7 @@ describe("SystemPromptPlugin", () => {
["gpt-4.1", "You are OpenCode, You and the user share the same workspace"],
["o3", "You are OpenCode, You and the user share the same workspace"],
["gpt-5-codex", "## Editing constraints"],
["gemini-2.5-pro", "# Core Mandates"],
["gemini-2.5-pro", fallback],
["claude-sonnet-4", "# Professional objectivity"],
["kimi-k2", "# Prompt and Tool Use"],
["trinity", "what command should I run to list files"],
@@ -160,15 +159,15 @@ describe("SystemPromptPlugin", () => {
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* SystemPromptPlugin.GooglePlugin.effect(pluginHost)
yield* SystemPromptPlugin.AnthropicPlugin.effect(pluginHost)
const gemini = context("gemini-2.5-pro")
const claude = context("claude-sonnet-4")
yield* hooks.trigger("session", "context", gemini)
yield* hooks.trigger("session", "context", claude)
expect(gemini.system[0]?.text).toContain("# Core Mandates")
expect(claude.system[0]?.text).toBe(fallback)
expect(gemini.system[0]?.text).toBe(fallback)
expect(claude.system[0]?.text).toContain("# Professional objectivity")
}),
)
+1
View File
@@ -23,6 +23,7 @@
},
"main": "./out/main/index.js",
"dependencies": {
"@effect/platform-node": "catalog:",
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
+63 -62
View File
@@ -1,103 +1,100 @@
import { execFile } from "node:child_process"
import { access, readFile, readdir } from "node:fs/promises"
import { dirname, extname, join } from "node:path"
import util from "node:util"
import { Effect, FileSystem, Path } from "effect"
const execFilePromise = util.promisify(execFile)
const exists = (path: string) =>
access(path)
.then(() => true)
.catch(() => false)
export function checkAppExists(appName: string) {
export const checkAppExists = Effect.fn("DesktopFiles.checkAppExists")(function* (appName: string) {
if (process.platform === "win32") return true
if (process.platform === "linux") return true
return checkMacosApp(appName)
}
return yield* checkMacosApp(appName)
})
export function resolveAppPath(appName: string) {
export const resolveAppPath = Effect.fn("DesktopFiles.resolveAppPath")(function* (appName: string) {
if (process.platform !== "win32") return appName
return resolveWindowsAppPath(appName)
}
return yield* resolveWindowsAppPath(appName)
})
async function checkMacosApp(appName: string) {
const checkMacosApp = Effect.fn("DesktopFiles.checkMacosApp")(function* (appName: string) {
const fs = yield* FileSystem.FileSystem
const locations = [`/Applications/${appName}.app`, `/System/Applications/${appName}.app`]
const home = process.env.HOME
if (home) locations.push(`${home}/Applications/${appName}.app`)
for (const location of locations) {
if (await exists(location)) return true
if (yield* exists(fs, location)) return true
}
return execFilePromise("which", [appName])
.then(() => true)
.catch(() => false)
}
return yield* Effect.tryPromise(() => execFilePromise("which", [appName])).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
})
async function resolveWindowsAppPath(appName: string): Promise<string | null> {
let output: string
try {
output = await execFilePromise("where", [appName]).then((r) => r.stdout.toString())
} catch {
return null
}
const resolveWindowsAppPath = Effect.fn("DesktopFiles.resolveWindowsAppPath")(function* (appName: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const result = yield* Effect.tryPromise(() => execFilePromise("where", [appName])).pipe(
Effect.catch(() => Effect.succeed(undefined)),
)
if (!result) return null
const paths = output
const paths = result.stdout
.toString()
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
const hasExt = (path: string, ext: string) => extname(path).toLowerCase() === `.${ext}`
const hasExt = (value: string, ext: string) => path.extname(value).toLowerCase() === `.${ext}`
const exe = paths.find((path) => hasExt(path, "exe"))
if (exe) return exe
const resolveCmd = async (path: string) => {
const content = await readFile(path, "utf8")
const resolveCmd = Effect.fnUntraced(function* (file: string) {
const content = yield* fs.readFileString(file)
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 base = path.dirname(file)
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)
if (part === "..") return path.dirname(current)
return path.join(current, part)
}, base)
if (await exists(resolved)) return resolved
if (yield* exists(fs, resolved)) return resolved
}
if (await exists(token)) return token
if (yield* exists(fs, token)) return token
}
return null
}
})
for (const path of paths) {
if (hasExt(path, "cmd") || hasExt(path, "bat")) {
const resolved = await resolveCmd(path)
for (const file of paths) {
if (hasExt(file, "cmd") || hasExt(file, "bat")) {
const resolved = yield* resolveCmd(file)
if (resolved) return resolved
}
if (!extname(path)) {
const cmd = `${path}.cmd`
if (await exists(cmd)) {
const resolved = await resolveCmd(cmd)
if (!path.extname(file)) {
const cmd = `${file}.cmd`
if (yield* exists(fs, cmd)) {
const resolved = yield* resolveCmd(cmd)
if (resolved) return resolved
}
const bat = `${path}.bat`
if (await exists(bat)) {
const resolved = await resolveCmd(bat)
const bat = `${file}.bat`
if (yield* exists(fs, bat)) {
const resolved = yield* resolveCmd(bat)
if (resolved) return resolved
}
}
@@ -110,27 +107,31 @@ async function resolveWindowsAppPath(appName: string): Promise<string | null> {
.join("")
if (key) {
for (const path of paths) {
const dirs = [dirname(path), dirname(dirname(path)), dirname(dirname(dirname(path)))]
for (const file of paths) {
const dirs = [path.dirname(file), path.dirname(path.dirname(file)), path.dirname(path.dirname(path.dirname(file)))]
for (const dir of dirs) {
try {
for (const entry of await readdir(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
const entries = yield* fs.readDirectory(dir).pipe(Effect.catch(() => Effect.succeed([])))
for (const entry of entries) {
const candidate = path.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
}
}
}
}
return paths[0] ?? null
})
function exists(fs: FileSystem.FileSystem, path: string) {
return fs.access(path).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
}
@@ -1,7 +1,9 @@
import { describe, expect, test } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node"
import { mkdtemp, rm, truncate, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Effect, FileSystem } from "effect"
import {
assertAttachmentBudget,
createPickedFileAuthorizations,
@@ -9,6 +11,9 @@ import {
readAttachment,
} from "./attachment-picker"
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) =>
Effect.runPromise(effect.pipe(Effect.provide(NodeFileSystem.layer)))
describe("assertAttachmentBudget", () => {
test("accepts selections within the media ingest limit", () => {
expect(() =>
@@ -25,7 +30,7 @@ describe("assertAttachmentBudget", () => {
const file = join(directory, "example.txt")
try {
await writeFile(file, "lorem ipsum")
expect(new TextDecoder().decode(await readAttachment(file))).toBe("lorem ipsum")
expect(new TextDecoder().decode(await run(readAttachment(file)))).toBe("lorem ipsum")
} finally {
await rm(directory, { recursive: true, force: true })
}
@@ -37,7 +42,7 @@ describe("assertAttachmentBudget", () => {
try {
await writeFile(file, "")
await truncate(file, MAX_ATTACHMENT_BYTES + 1)
await expect(readAttachment(file)).rejects.toThrow("20 MB limit")
await expect(run(readAttachment(file))).rejects.toThrow("20 MB limit")
} finally {
await rm(directory, { recursive: true, force: true })
}
@@ -45,16 +50,16 @@ describe("assertAttachmentBudget", () => {
})
describe("picked file authorizations", () => {
const read = async (path: string) => new TextEncoder().encode(path).buffer
const read = (path: string) => Effect.sync(() => new TextEncoder().encode(path).buffer)
test("keeps concurrent picker selections isolated", async () => {
const authorizations = createPickedFileAuthorizations(read)
const first = authorizations.add(1, ["a.txt", "b.txt"])
const second = authorizations.add(1, ["c.txt"])
expect(new TextDecoder().decode(await authorizations.read(1, first, "a.txt"))).toBe("a.txt")
expect(new TextDecoder().decode(await authorizations.read(1, second, "c.txt"))).toBe("c.txt")
expect(new TextDecoder().decode(await authorizations.read(1, first, "b.txt"))).toBe("b.txt")
expect(new TextDecoder().decode(await run(authorizations.read(1, first, "a.txt")))).toBe("a.txt")
expect(new TextDecoder().decode(await run(authorizations.read(1, second, "c.txt")))).toBe("c.txt")
expect(new TextDecoder().decode(await run(authorizations.read(1, first, "b.txt")))).toBe("b.txt")
})
test("releases unread files for one picker without affecting another", async () => {
@@ -63,25 +68,29 @@ describe("picked file authorizations", () => {
const second = authorizations.add(1, ["b.txt"])
authorizations.release(1, first)
await expect(authorizations.read(1, first, "a.txt")).rejects.toThrow("not selected")
expect(new TextDecoder().decode(await authorizations.read(1, second, "b.txt"))).toBe("b.txt")
await expect(run(authorizations.read(1, first, "a.txt"))).rejects.toThrow("not selected")
expect(new TextDecoder().decode(await run(authorizations.read(1, second, "b.txt")))).toBe("b.txt")
})
test("keeps picker tokens scoped to their renderer", async () => {
const authorizations = createPickedFileAuthorizations(read)
const token = authorizations.add(1, ["a.txt"])
await expect(authorizations.read(2, token, "a.txt")).rejects.toThrow("not selected")
await expect(run(authorizations.read(2, token, "a.txt"))).rejects.toThrow("not selected")
})
test("charges actual reads against the selection budget", async () => {
const authorizations = createPickedFileAuthorizations(async (_path, maxBytes) => {
if (6 > maxBytes) throw new Error("budget exceeded")
return new ArrayBuffer(6)
}, 10)
const authorizations = createPickedFileAuthorizations(
(_path, maxBytes) =>
Effect.sync(() => {
if (6 > maxBytes) throw new Error("budget exceeded")
return new ArrayBuffer(6)
}),
10,
)
const token = authorizations.add(1, ["a.txt", "b.txt"])
await authorizations.read(1, token, "a.txt")
await expect(authorizations.read(1, token, "b.txt")).rejects.toThrow("budget exceeded")
await run(authorizations.read(1, token, "a.txt"))
await expect(run(authorizations.read(1, token, "b.txt"))).rejects.toThrow("budget exceeded")
})
})
@@ -1,11 +1,11 @@
import { randomUUID } from "node:crypto"
import { open } from "node:fs/promises"
import { Effect, FileSystem } from "effect"
import { nativeT } from "../native/translations"
export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
export function createPickedFileAuthorizations(
read: (path: string, maxBytes: number) => Promise<ArrayBuffer> = readAttachment,
read: (path: string, maxBytes: number) => Effect.Effect<ArrayBuffer, unknown>,
budget = MAX_ATTACHMENT_BYTES,
) {
const selections = new Map<string, { sender: number; paths: Set<string>; remaining: number }>()
@@ -16,15 +16,15 @@ export function createPickedFileAuthorizations(
selections.set(token, { sender, paths: new Set(paths), remaining: budget })
return token
},
async read(sender: number, token: string, path: string) {
read: Effect.fn("DesktopFiles.readPickedFile")(function* (sender: number, token: string, path: string) {
const selection = selections.get(token)
if (selection?.sender !== sender || !selection.paths.delete(path))
throw new Error(nativeT("desktop.picker.error.notSelected"))
const bytes = await read(path, selection.remaining)
const bytes = yield* read(path, selection.remaining)
selection.remaining -= bytes.byteLength
if (selection.paths.size === 0) selections.delete(token)
return bytes
},
}),
release(sender: number, token: string) {
if (selections.get(token)?.sender === sender) selections.delete(token)
},
@@ -37,21 +37,23 @@ export function assertAttachmentBudget(files: { size: number }[]) {
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
}
export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
const file = await open(filePath, "r")
try {
const info = await file.stat()
if (info.size > maxBytes)
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
const bytes = Buffer.allocUnsafe(info.size)
let offset = 0
while (offset < info.size) {
const result = await file.read(bytes, offset, info.size - offset, offset)
if (result.bytesRead === 0) break
offset += result.bytesRead
}
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + offset) as ArrayBuffer
} finally {
await file.close()
}
export function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) {
return Effect.scoped(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const file = yield* fs.open(filePath, { flag: "r" })
const info = yield* file.stat
if (info.size > FileSystem.Size(maxBytes))
throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 }))
const bytes = new Uint8Array(Number(info.size))
let offset = 0
while (offset < bytes.byteLength) {
const read = Number(yield* file.read(bytes.subarray(offset)))
if (read === 0) break
offset += read
}
return bytes.buffer.slice(0, offset)
}),
)
}
+85 -57
View File
@@ -1,69 +1,98 @@
export * as DesktopFiles from "./index"
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import { basename } from "node:path"
import { clipboard, dialog, shell } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import type { DirectoryPickerOptions, FilePickerOptions, SaveFilePickerOptions } from "../../shared/ipc-contract"
import { writeLog } from "../native/logging"
import { scoped } from "../native/logging"
import { nativeT } from "../native/translations"
import { assertAttachmentBudget, createPickedFileAuthorizations } from "./attachment-picker"
import { assertAttachmentBudget, createPickedFileAuthorizations, readAttachment } from "./attachment-picker"
import { resolveExternalURL, resolveLocalFilePath } from "./external-url"
export function createFileCapabilities() {
const pickedFiles = createPickedFileAuthorizations()
export type Interface = ReturnType<typeof make>
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopFiles") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
return Service.of(make(fs, path))
}),
)
function make(fs: FileSystem.FileSystem, path: Path.Path) {
const pickedFiles = createPickedFileAuthorizations((file, maxBytes) =>
readAttachment(file, maxBytes).pipe(Effect.provideService(FileSystem.FileSystem, fs)),
)
return {
async openDirectoryPicker(options?: DirectoryPickerOptions) {
const result = await dialog.showOpenDialog({
properties: ["openDirectory", ...(options?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: options?.title ?? nativeT("desktop.dialog.chooseFolder"),
defaultPath: options?.defaultPath,
})
openDirectoryPicker: Effect.fn("DesktopFiles.openDirectoryPicker")(function* (options?: DirectoryPickerOptions) {
const result = yield* Effect.promise(() =>
dialog.showOpenDialog({
properties: ["openDirectory", ...(options?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
title: options?.title ?? nativeT("desktop.dialog.chooseFolder"),
defaultPath: options?.defaultPath,
}),
)
if (result.canceled) return null
return options?.multiple ? result.filePaths : result.filePaths[0]
},
async openFilePicker(sender: number, options?: FilePickerOptions) {
const result = await dialog.showOpenDialog({
properties: ["openFile", ...(options?.multiple ? ["multiSelections" as const] : [])],
title: options?.title ?? nativeT("desktop.dialog.chooseFile"),
defaultPath: options?.defaultPath,
filters: pickerFilters(options?.extensions),
})
}),
openFilePicker: Effect.fn("DesktopFiles.openFilePicker")(function* (sender: number, options?: FilePickerOptions) {
const result = yield* Effect.promise(() =>
dialog.showOpenDialog({
properties: ["openFile", ...(options?.multiple ? ["multiSelections" as const] : [])],
title: options?.title ?? nativeT("desktop.dialog.chooseFile"),
defaultPath: options?.defaultPath,
filters: pickerFilters(options?.extensions),
}),
)
if (result.canceled) return null
const files = await Promise.all(
result.filePaths.map(async (path) => ({ path, name: basename(path), size: (await stat(path)).size })),
const files = yield* Effect.forEach(
result.filePaths,
Effect.fnUntraced(function* (file) {
const info = yield* fs.stat(file)
return { path: file, name: path.basename(file), size: Number(info.size) }
}),
{ concurrency: "unbounded" },
)
assertAttachmentBudget(files)
return { token: pickedFiles.add(sender, result.filePaths), files }
},
readPickedFile: (sender: number, token: string, path: string) => pickedFiles.read(sender, token, path),
releasePickedFiles: (sender: number, token: string) => pickedFiles.release(sender, token),
async saveFilePicker(options?: SaveFilePickerOptions) {
const result = await dialog.showSaveDialog({
title: options?.title ?? nativeT("desktop.dialog.saveFile"),
defaultPath: options?.defaultPath,
})
}),
readPickedFile: pickedFiles.read,
releasePickedFiles: pickedFiles.release,
saveFilePicker: Effect.fn("DesktopFiles.saveFilePicker")(function* (options?: SaveFilePickerOptions) {
const result = yield* Effect.promise(() =>
dialog.showSaveDialog({
title: options?.title ?? nativeT("desktop.dialog.saveFile"),
defaultPath: options?.defaultPath,
}),
)
if (result.canceled) return null
return result.filePath ?? null
},
async openPath(path: string, application?: string) {
if (!application) return shell.openPath(path)
await new Promise<void>((resolve, reject) => {
const command =
process.platform === "darwin"
? { file: "open", arguments: ["-a", application, path] }
: { file: application, arguments: [path] }
execFile(command.file, command.arguments, (error) => (error ? reject(error) : resolve()))
})
},
async revealPath(path: string) {
const exists = await stat(path).then(
() => true,
() => false,
}),
openPath: Effect.fn("DesktopFiles.openPath")(function* (target: string, application?: string) {
if (!application) return yield* Effect.promise(() => shell.openPath(target))
yield* Effect.tryPromise(() =>
new Promise<void>((resolve, reject) => {
const command =
process.platform === "darwin"
? { file: "open", arguments: ["-a", application, target] }
: { file: application, arguments: [target] }
execFile(command.file, command.arguments, (error) => (error ? reject(error) : resolve()))
}),
)
}),
revealPath: Effect.fn("DesktopFiles.revealPath")(function* (target: string) {
const exists = yield* fs.stat(target).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
if (!exists) return false
shell.showItemInFolder(path)
shell.showItemInFolder(target)
return true
},
}),
readClipboardImage() {
const image = clipboard.readImage()
if (image.isEmpty()) return null
@@ -73,25 +102,24 @@ export function createFileCapabilities() {
}
}
export function openExternalURL(value: string) {
export const openExternalURL = Effect.fn("DesktopFiles.openExternalURL")(function* (value: string) {
const url = resolveExternalURL(value)
if (!url) {
writeLog("window", "blocked external target", { url: value }, "warn")
yield* scoped("window", Effect.logWarning("blocked external target", { url: value }))
return
}
void shell.openExternal(url)
}
yield* Effect.promise(() => shell.openExternal(url))
})
export function openLocalFileURL(value: string) {
export const openLocalFileURL = Effect.fn("DesktopFiles.openLocalFileURL")(function* (value: string) {
const path = resolveLocalFilePath(value)
if (!path) {
writeLog("window", "blocked local file target", { url: value }, "warn")
yield* scoped("window", Effect.logWarning("blocked local file target", { url: value }))
return
}
void shell.openPath(path).then((error) => {
if (error) writeLog("window", "failed to open local file", { path, error }, "error")
})
}
const error = yield* Effect.promise(() => shell.openPath(path))
if (error) yield* scoped("window", Effect.logError("failed to open local file", { path, error }))
})
function pickerFilters(extensions?: string[]) {
if (!extensions?.length) return undefined
+27 -106
View File
@@ -1,111 +1,32 @@
import { NodeFileSystem, NodePath, NodeRuntime } from "@effect/platform-node"
import { app } from "electron"
import { Deferred, Effect, Fiber } from "effect"
import type { ServerReadyData } from "../shared/ipc-contract"
import { checkAppExists, resolveAppPath } from "./files/apps"
import {
registerIpcHandlers,
registerUpdaterIpcHandlers,
registerWslInitialization,
registerWslIpcHandlers,
} from "./ipc"
import {
acquireApplicationLock,
configureApplication,
loadProxyEnvironment,
preferApplicationEnvironment,
prepareDesktop,
} from "./lifecycle/environment"
import { createApplicationLifecycle } from "./lifecycle"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "./lifecycle/onboarding"
import { exportDebugLogs, startNetworkLogging, writeLog } from "./native/logging"
import { createMenu, sendMenuCommand } from "./native/menu"
import { setNativeTranslations } from "./native/translations"
import { startBackgroundCli } from "./service/background-service"
import { forwardInitializationFailure } from "./service/initialization"
import { getDefaultServerUrl, setDefaultServerUrl } from "./service/server-settings"
import { createUpdaterIpc, setupAutoUpdater, showUpdaterDialog, startAutoUpdater } from "./updater"
import { getLastFocusedWindow, setBackgroundColor } from "./windows"
import { startWsl } from "./wsl/start"
import { Effect, Layer } from "effect"
import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { ApplicationLifecycle } from "./lifecycle"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
const main = Effect.gen(function* () {
const logger = configureApplication()
if (!acquireApplicationLock()) return
preferApplicationEnvironment(logger)
loadProxyEnvironment(logger)
const lifecycle = createApplicationLifecycle(logger)
const serverReady = Deferred.makeUnsafe<ServerReadyData, unknown>()
const wslReady = Promise.withResolvers<void>()
logger.log("starting v2 background service")
const backgroundTask = yield* Effect.promise(() => startBackgroundCli(logger)).pipe(Effect.forkChild)
yield* Effect.promise(() => app.whenReady())
yield* prepareDesktop(logger)
const updater = yield* Effect.promise(() => setupAutoUpdater(lifecycle.prepareToRestart))
const menu = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => void showUpdaterDialog(updater),
relaunch: lifecycle.relaunch,
}
registerIpcHandlers({
relaunch: lifecycle.relaunch,
awaitInitialization: Effect.fnUntraced(
function* () {
logger.log("awaiting server ready")
const result = yield* Deferred.await(serverReady)
logger.log("server ready", { url: result.url })
return result
},
(effect) => Effect.runPromise(effect),
),
consumeInitialDeepLinks: lifecycle.consumeInitialDeepLinks,
getDefaultServerUrl,
setDefaultServerUrl,
isFirstLaunchOnboardingPending,
finishFirstLaunchOnboarding,
checkAppExists,
resolveAppPath: async (appName) => resolveAppPath(appName),
showUpdater: () => showUpdaterDialog(updater),
setBackgroundColor,
exportDebugLogs,
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
setNativeTranslations: (bundle) => {
if (setNativeTranslations(bundle)) createMenu(menu)
},
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
app.once("will-quit", quit)
return Effect.sync(() => app.off("will-quit", quit))
})
registerUpdaterIpcHandlers(createUpdaterIpc(updater))
registerWslInitialization(wslReady.promise)
startAutoUpdater(updater)
yield* Effect.promise(() => startNetworkLogging())
const loadingTask = yield* Effect.gen(function* () {
const background = yield* Fiber.join(backgroundTask)
yield* Deferred.succeed(serverReady, {
url: background.url,
username: background.username,
password: background.password,
})
logger.log("loading task finished")
void startWsl(background, logger).then(
(wsl) => {
registerWslIpcHandlers(wsl.ipc)
lifecycle.setWslShutdown(wsl.stop)
wsl.start()
wslReady.resolve()
},
(error) => {
logger.error("failed to start WSL manager", { error })
wslReady.reject(error)
},
)
}).pipe(forwardInitializationFailure(serverReady), Effect.forkChild)
if (lifecycle.restoreWindows().length) createMenu(menu)
yield* Fiber.await(loadingTask)
})
Effect.runFork(main)
runIpc().pipe(
Effect.provide(Ipc.layer),
Effect.provide(BackgroundService.layer),
Effect.provide(DesktopCli.layer),
Effect.provide(UpdaterLive.layer),
Effect.provide(DesktopInitialization.layer),
Effect.provide(ApplicationLifecycle.layer),
Effect.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer)),
Effect.scoped,
NodeRuntime.runMain,
)
+26
View File
@@ -0,0 +1,26 @@
import type { WebContents } from "electron"
import { Effect, Queue, Stream } from "effect"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
const queues = new Map<number, Queue.Queue<DesktopEvent>>()
export const bindIpcEvents = Effect.fn("IpcEvents.bind")(function* (senderId: number) {
const queue = yield* Queue.unbounded<DesktopEvent>()
const previous = queues.get(senderId)
queues.set(senderId, queue)
if (previous) yield* Queue.shutdown(previous)
return Effect.fnUntraced(function* () {
if (queues.get(senderId) === queue) queues.delete(senderId)
yield* Queue.shutdown(queue)
})()
})
export function ipcEventStream(senderId: number) {
const queue = queues.get(senderId)
return queue ? Stream.fromQueue(queue) : Stream.empty
}
export function emitIpcEvent(sender: WebContents, event: DesktopEvent) {
const queue = queues.get(sender.id)
if (queue) Queue.offerUnsafe(queue, event)
}
@@ -0,0 +1,71 @@
import { BrowserWindow } from "electron"
import { parseDesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import { Effect } from "effect"
import { AppRpcs } from "../../shared/ipc-rpc"
import { openExternalURL } from "../files"
import { checkAppExists, resolveAppPath } from "../files/apps"
import { setForceFocus } from "../native/debug"
import { DesktopLogging, scoped } from "../native/logging"
import { createMenu, sendMenuCommand } from "../native/menu"
import { setNativeTranslations } from "../native/translations"
import { IpcPortHandoff } from "../ipc-transport"
import { ApplicationLifecycle } from "../lifecycle"
import { finishFirstLaunchOnboarding, isFirstLaunchOnboardingPending } from "../lifecycle/onboarding"
import { BackgroundService } from "../service/background-service"
import { getDefaultServerUrl, setDefaultServerUrl } from "../service/server-settings"
import { Updater } from "../updater"
import { getLastFocusedWindow, setBackgroundColor } from "../windows"
import { sender } from "./context"
export const appHandlers = AppRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const background = yield* BackgroundService.Service
const updater = yield* Updater.Service
const logging = yield* DesktopLogging.Service
const runFork = Effect.runForkWith(yield* Effect.context())
return AppRpcs.of({
AppAwaitInitialization: () => background.connection,
AppConsumeInitialDeepLinks: () => Effect.sync(lifecycle.consumeInitialDeepLinks),
AppGetDefaultServerUrl: () => Effect.sync(getDefaultServerUrl),
AppSetDefaultServerUrl: ({ url }) => Effect.sync(() => setDefaultServerUrl(url)),
AppIsFirstLaunchOnboardingPending: isFirstLaunchOnboardingPending,
AppFinishFirstLaunchOnboarding: ({ createDefaultProject }) =>
finishFirstLaunchOnboarding(createDefaultProject).pipe(Effect.orDie),
AppCheckAppExists: ({ appName }) => checkAppExists(appName).pipe(Effect.orDie),
AppResolveAppPath: ({ appName }) => resolveAppPath(appName).pipe(Effect.orDie),
AppSetBackgroundColor: ({ color }) => Effect.sync(() => setBackgroundColor(color)),
AppExportDebugLogs: () => logging.exportDebug,
AppSetForceFocus: ({ enabled }, context) => promise(() => setForceFocus(sender(handoff, context), enabled)),
AppRecordFatalRendererError: ({ error }) =>
scoped("renderer", Effect.logError("fatal renderer error", { ...error })),
AppSetNativeTranslations: ({ value }, context) =>
Effect.sync(() => {
const contents = sender(handoff, context)
const win = BrowserWindow.fromWebContents(contents)
if (!win || win.isDestroyed() || win.webContents !== contents) {
throw new Error("Invalid native translation sender")
}
const bundle = parseDesktopNativeBundle(value)
if (!bundle) throw new Error("Invalid native translation bundle")
if (!setNativeTranslations(bundle)) return
createMenu({
trigger: (id) => {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
createWindow: lifecycle.createWindow,
openExternal: (url) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
})
}),
AppRelaunch: () => Effect.sync(lifecycle.relaunch),
})
}),
)
function promise<A>(evaluate: () => A | Promise<A>) {
return Effect.tryPromise(async () => evaluate()).pipe(Effect.orDie)
}
@@ -0,0 +1,9 @@
import type { IpcPortHandoff } from "../ipc-transport"
export type RpcContext = { readonly client: { readonly id: number } }
export function sender(handoff: IpcPortHandoff["Service"], context: RpcContext) {
const contents = handoff.sender(context.client.id)
if (!contents || contents.isDestroyed()) throw new Error("Renderer connection not found")
return contents
}
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import { EventRpcs } from "../../shared/ipc-rpc"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export const eventHandlers = EventRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return EventRpcs.of({
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
})
}),
)
@@ -0,0 +1,42 @@
import { Effect } from "effect"
import { FileRpcs } from "../../shared/ipc-rpc"
import { DesktopFiles, openExternalURL, openLocalFileURL } from "../files"
import { IpcPortHandoff } from "../ipc-transport"
import { sender } from "./context"
export const fileHandlers = FileRpcs.toLayer(
Effect.gen(function* () {
const files = yield* DesktopFiles.Service
const handoff = yield* IpcPortHandoff
return FileRpcs.of({
FilesOpenDirectoryPicker: ({ options }) => files.openDirectoryPicker(options),
FilesOpenFilePicker: ({ options }, context) =>
files
.openFilePicker(
sender(handoff, context).id,
options ? { ...options, extensions: options.extensions && [...options.extensions] } : undefined,
)
.pipe(Effect.orDie),
FilesReadPickedFile: ({ token, path }, context) =>
files
.readPickedFile(sender(handoff, context).id, token, path)
.pipe(Effect.map((buffer) => new Uint8Array(buffer)), Effect.orDie),
FilesReleasePickedFiles: ({ token }, context) =>
Effect.sync(() => files.releasePickedFiles(sender(handoff, context).id, token)),
FilesSaveFilePicker: ({ options }) => files.saveFilePicker(options),
FilesOpenExternal: ({ url }) => openExternalURL(url),
FilesOpenLocalFile: ({ url }) => openLocalFileURL(url),
FilesOpenPath: ({ path, application }) =>
files.openPath(path, application).pipe(
Effect.map((result) => result ?? null),
Effect.orDie,
),
FilesRevealPath: ({ path }) => files.revealPath(path),
FilesReadClipboardImage: () =>
Effect.sync(() => {
const image = files.readClipboardImage()
return image ? { ...image, buffer: new Uint8Array(image.buffer) } : null
}),
})
}),
)
@@ -0,0 +1,27 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { MenuRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { ApplicationLifecycle } from "../lifecycle"
import { runDesktopMenuAction } from "../native/menu-actions"
import { Updater } from "../updater"
import { sender } from "./context"
export const menuHandlers = MenuRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const updater = yield* Updater.Service
const runFork = Effect.runForkWith(yield* Effect.context())
return MenuRpcs.of({
MenuRunAction: ({ action }, context) =>
Effect.sync(() =>
runDesktopMenuAction(BrowserWindow.fromWebContents(sender(handoff, context)), action, {
checkForUpdates: () => runFork(updater.show),
createWindow: lifecycle.createWindow,
relaunch: lifecycle.relaunch,
}),
),
})
}),
)
@@ -0,0 +1,29 @@
import { Effect } from "effect"
import { StorageRpcs } from "../../shared/ipc-rpc"
import { DesktopStorage } from "../storage"
export const storageHandlers = StorageRpcs.toLayer(
Effect.gen(function* () {
const storage = yield* DesktopStorage.Service
return StorageRpcs.of({
StorageGet: ({ name, key }) => Effect.sync(() => storage.get(name, key)),
StorageSet: ({ name, key, value }) => Effect.sync(() => storage.set(name, key, value)),
StorageDelete: ({ name, key }) => storage.deleteValue(name, key).pipe(Effect.orDie),
StorageClear: ({ name }) => storage.clear(name).pipe(Effect.orDie),
StorageKeys: ({ name }) => Effect.sync(() => storage.keys(name)),
StorageLength: ({ name }) => Effect.sync(() => storage.length(name)),
DraftsGet: ({ key }) => Effect.sync(() => storage.drafts.get(key)),
DraftsSet: ({ key, value }) => Effect.sync(() => storage.drafts.set(key, value)),
DraftsDelete: ({ key }) => Effect.sync(() => storage.drafts.set(key, null)),
DraftsPutBlob: ({ data }) =>
Effect.sync(() =>
storage.drafts.putBlob(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer),
),
DraftsGetBlob: ({ id }) =>
Effect.sync(() => {
const data = storage.drafts.getBlob(id)
return data ? new Uint8Array(data) : null
}),
})
}),
)
@@ -0,0 +1,18 @@
import { Effect } from "effect"
import { UpdaterRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { Updater } from "../updater"
import { sender } from "./context"
export const updaterHandlers = UpdaterRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const updater = yield* Updater.Service
return UpdaterRpcs.of({
UpdaterSubscribe: (_args, context) => updater.subscribe(sender(handoff, context)),
UpdaterUnsubscribe: (_args, context) => updater.unsubscribe(sender(handoff, context).id),
UpdaterCheck: () => updater.check,
UpdaterInstall: () => updater.install,
})
}),
)
@@ -0,0 +1,58 @@
import { BrowserWindow } from "electron"
import { Effect } from "effect"
import { WindowRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import {
getPinchZoomEnabled,
getWindowID,
setPinchZoomEnabled,
setTitlebar,
setWindowThemeReady,
updateTitlebar,
} from "../windows"
import { sender } from "./context"
export const windowHandlers = WindowRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return WindowRpcs.of({
WindowGetId: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
}),
WindowThemeReady: (_args, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (!win) throw new Error("Window not found")
setWindowThemeReady(win)
}),
WindowGetFocused: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFocused() ?? false),
WindowGetFullscreen: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.isFullScreen() ?? false),
WindowSetFocus: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.focus()),
WindowShow: (_args, context) =>
Effect.sync(() => BrowserWindow.fromWebContents(sender(handoff, context))?.show()),
WindowGetZoomFactor: (_args, context) => Effect.sync(() => sender(handoff, context).getZoomFactor()),
WindowSetZoomFactor: ({ factor }, context) =>
Effect.sync(() => {
const contents = sender(handoff, context)
contents.setZoomFactor(factor)
const win = BrowserWindow.fromWebContents(contents)
if (win) updateTitlebar(win)
}),
WindowGetPinchZoomEnabled: () => Effect.sync(getPinchZoomEnabled),
WindowSetPinchZoomEnabled: ({ enabled }) => Effect.sync(() => setPinchZoomEnabled(enabled)),
WindowSetTitlebar: ({ theme }, context) =>
Effect.sync(() => {
const win = BrowserWindow.fromWebContents(sender(handoff, context))
if (win) setTitlebar(win, theme)
}),
})
}),
)
@@ -0,0 +1,27 @@
import { Effect } from "effect"
import { WslRpcs } from "../../shared/ipc-rpc"
import { IpcPortHandoff } from "../ipc-transport"
import { Wsl } from "../wsl/start"
import { sender } from "./context"
export const wslHandlers = WslRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const wsl = yield* Wsl.Service
return WslRpcs.of({
WslSubscribe: (_args, context) => wsl.subscribe(sender(handoff, context)),
WslUnsubscribe: (_args, context) => wsl.unsubscribe(sender(handoff, context).id),
WslGetState: () => wsl.getState(),
WslProbeRuntime: () => wsl.probeRuntime(),
WslRefreshDistros: () => wsl.refreshDistros(),
WslInstallWsl: () => wsl.installWsl(),
WslInstallDistro: ({ name }) => wsl.installDistro(name),
WslProbeAddable: ({ distros }) => wsl.probeAddable([...distros]),
WslInstallOpencode: ({ name }) => wsl.installOpencode(name),
WslOpenTerminal: ({ name }) => wsl.openTerminal(name),
WslAddServer: ({ distro }) => wsl.addServer(distro),
WslRemoveServer: ({ id }) => wsl.removeServer(id),
WslStartServer: ({ id }) => wsl.startServer(id),
})
}),
)
@@ -0,0 +1,181 @@
import { describe, expect, test } from "bun:test"
import { EventEmitter } from "node:events"
import { MessageChannel } from "node:worker_threads"
import type { MessagePortMain, WebContents } from "electron"
import { Context, Effect, Layer, ManagedRuntime, Option, Queue, Schema, Stream } from "effect"
import { Rpc, RpcClient, RpcClientError, RpcGroup, RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
import { IpcPortHandoff, IpcServerProtocolLive } from "./ipc-transport"
describe("desktop RPC transport", () => {
test("keeps multiple renderer ports independent", async () => {
const handlers = TestRpcs.toLayer(
Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
return TestRpcs.of({
"test.focused": (_request, context) => Effect.succeed(handoff.sender(context.client.id)?.id === 1),
"test.blob.put": ({ data }) => Effect.succeed([...data].join(",")),
"test.blob.get": () => Effect.succeed(new Uint8Array([3, 1, 4])),
"test.events": () => Stream.make(new TestEvent({ value: "session.new" })),
})
}),
)
const live = RpcServer.layer(TestRpcs).pipe(Layer.provide(handlers), Layer.provideMerge(IpcServerProtocolLive))
const runtime = ManagedRuntime.make(live)
const handoff = await runtime.runPromise(IpcPortHandoff)
const first = new MessageChannel()
const second = new MessageChannel()
handoff.bind(sender(1), serverPort(first.port1))
handoff.bind(sender(2), serverPort(second.port1))
const firstClient = makeClient(first.port2)
const secondClient = makeClient(second.port2)
const [focused, unfocused] = await Promise.all([callFocused(firstClient), callFocused(secondClient)])
expect(focused).toBe(true)
expect(unfocused).toBe(false)
expect(await putBlob(firstClient, new Uint8Array([2, 7, 1]))).toBe("2,7,1")
expect(await getBlob(firstClient)).toEqual(new Uint8Array([3, 1, 4]))
expect(await firstEvent(firstClient)).toEqual(new TestEvent({ value: "session.new" }))
const reloaded = new MessageChannel()
handoff.bind(sender(1), serverPort(reloaded.port1))
const reloadedClient = makeClient(reloaded.port2)
const [reloadedFocused, stillUnfocused] = await Promise.all([
callFocused(reloadedClient),
callFocused(secondClient),
])
expect(reloadedFocused).toBe(true)
expect(stillUnfocused).toBe(false)
await Promise.all([firstClient.dispose(), secondClient.dispose(), reloadedClient.dispose()])
await runtime.dispose()
})
})
class TestEvent extends Schema.TaggedClass<TestEvent>()("TestEvent", { value: Schema.String }) {}
const TestRpcs = RpcGroup.make(
Rpc.make("test.focused", { success: Schema.Boolean }),
Rpc.make("test.blob.put", { payload: { data: Schema.Uint8Array }, success: Schema.String }),
Rpc.make("test.blob.get", { success: Schema.Uint8Array }),
Rpc.make("test.events", { success: TestEvent, stream: true }),
)
type TestRpcClient = RpcClient.FromGroup<typeof TestRpcs, RpcClientError.RpcClientError>
class TestClient extends Context.Service<TestClient, TestRpcClient>()("opencode/desktop/TestClient") {}
function makeClient(port: MessagePort) {
return ManagedRuntime.make(
Layer.effect(TestClient, RpcClient.make(TestRpcs)).pipe(Layer.provide(clientProtocol(port))),
)
}
function callFocused(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.focused"]()
}),
)
}
function putBlob(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>, data: Uint8Array) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.blob.put"]({ data })
}),
)
}
function getBlob(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.blob.get"]()
}),
)
}
function firstEvent(runtime: ManagedRuntime.ManagedRuntime<TestClient, never>) {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* TestClient
return yield* client["test.events"]().pipe(Stream.runHead, Effect.map(Option.getOrThrow))
}),
)
}
function clientProtocol(port: MessagePort) {
return Layer.effect(
RpcClient.Protocol,
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const serialization = yield* RpcSerialization.RpcSerialization
const parser = serialization.makeUnsafe()
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) =>
parser
.decode(event.data)
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
port.addEventListener("message", onMessage)
port.start()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
port.removeEventListener("message", onMessage)
port.close()
}),
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach((message) =>
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
),
Effect.forkScoped,
)
return {
send: (_clientId: number, request: RpcMessage.FromClientEncoded) =>
Effect.sync(() => {
const encoded = parser.encode(request)
if (encoded !== undefined) port.postMessage(encoded)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
}
function sender(id: number) {
const events = new EventEmitter()
return {
id,
isDestroyed: () => false,
once: events.once.bind(events),
off: events.off.bind(events),
} as unknown as WebContents
}
function serverPort(port: import("node:worker_threads").MessagePort) {
const listeners = new Map<(event: Electron.MessageEvent) => void, (data: unknown) => void>()
return {
on(event: string, listener: (event: Electron.MessageEvent) => void) {
if (event !== "message") {
port.on(event, listener)
return
}
const wrapped = (data: unknown) => listener({ data } as Electron.MessageEvent)
listeners.set(listener, wrapped)
port.on("message", wrapped)
},
off(event: string, listener: (event: Electron.MessageEvent) => void) {
if (event !== "message") {
port.off(event, listener)
return
}
const wrapped = listeners.get(listener)
if (wrapped) port.off("message", wrapped)
},
postMessage: port.postMessage.bind(port),
start: port.start.bind(port),
close: port.close.bind(port),
} as unknown as MessagePortMain
}
+124
View File
@@ -0,0 +1,124 @@
import type { MessagePortMain, WebContents } from "electron"
import { Context, Effect, Layer, Option, Queue, Stream } from "effect"
import { RpcMessage, RpcSerialization, RpcServer } from "effect/unstable/rpc"
import { bindIpcEvents } from "./ipc-events"
type PortBinding = {
readonly id: number
readonly sender: WebContents
readonly port: MessagePortMain
readonly parser: RpcSerialization.Parser
readonly onMessage: (event: Electron.MessageEvent) => void
readonly onClose: () => void
readonly unbindEvents: Effect.Effect<void>
}
type Handoff = {
readonly bind: (sender: WebContents, port: MessagePortMain) => void
readonly sender: (clientId: number) => WebContents | undefined
}
export class IpcPortHandoff extends Context.Service<IpcPortHandoff, Handoff>()("opencode/desktop/IpcPortHandoff") {}
export const IpcServerProtocolLive = Layer.unwrap(
Effect.gen(function* () {
const handoffs = yield* Queue.unbounded<readonly [WebContents, MessagePortMain]>()
const bindings = new Map<number, PortBinding>()
const senderBindings = new Map<number, number>()
const protocol = Layer.effect(
RpcServer.Protocol,
RpcServer.Protocol.make(
Effect.fnUntraced(function* (writeRequest) {
const serialization = yield* RpcSerialization.RpcSerialization
const disconnects = yield* Queue.unbounded<number>()
const inbound = yield* Queue.unbounded<readonly [number, RpcMessage.FromClientEncoded]>()
const runFork = Effect.runForkWith(yield* Effect.context())
let nextClientId = 0
const disconnect = Effect.fnUntraced(function* (id: number) {
const binding = bindings.get(id)
if (!binding) return
bindings.delete(id)
if (senderBindings.get(binding.sender.id) === id) senderBindings.delete(binding.sender.id)
binding.port.off("message", binding.onMessage)
binding.port.off("close", binding.onClose)
binding.sender.off("destroyed", binding.onClose)
yield* binding.unbindEvents
binding.port.close()
Queue.offerUnsafe(disconnects, id)
})
const bind = Effect.fnUntraced(function* (sender: WebContents, port: MessagePortMain) {
const previous = senderBindings.get(sender.id)
if (previous !== undefined) yield* disconnect(previous)
if (sender.isDestroyed()) {
port.close()
return
}
const id = nextClientId++
const parser = serialization.makeUnsafe()
const onMessage = (event: Electron.MessageEvent) => {
try {
parser
.decode(event.data)
.forEach((message) =>
Queue.offerUnsafe(inbound, [id, message as RpcMessage.FromClientEncoded] as const),
)
} catch {
return
}
}
const onClose = () => runFork(disconnect(id))
const unbindEvents = yield* bindIpcEvents(sender.id)
const binding = { id, sender, port, parser, onMessage, onClose, unbindEvents }
bindings.set(id, binding)
senderBindings.set(sender.id, id)
port.on("message", onMessage)
port.on("close", onClose)
sender.once("destroyed", onClose)
port.start()
})
yield* Stream.fromQueue(handoffs).pipe(
Stream.runForEach(([sender, port]) => bind(sender, port)),
Effect.forkScoped,
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach(([id, message]) => (bindings.has(id) ? writeRequest(id, message) : Effect.void)),
Effect.forkScoped,
)
yield* Effect.addFinalizer(() => Effect.forEach([...bindings.keys()], disconnect, { discard: true }))
return {
disconnects,
send: (clientId, response) =>
Effect.sync(() => {
const binding = bindings.get(clientId)
if (!binding) return
const encoded = binding.parser.encode(response)
if (encoded !== undefined) binding.port.postMessage(encoded)
}),
end: disconnect,
clientIds: Effect.sync(() => new Set(bindings.keys())),
initialMessage: Effect.succeed(Option.none()),
supportsAck: true,
supportsTransferables: false,
supportsSpanPropagation: false,
}
}),
),
)
return Layer.merge(
protocol,
Layer.succeed(IpcPortHandoff)({
bind: (sender, port) => {
Queue.offerUnsafe(handoffs, [sender, port])
},
sender: (clientId) => bindings.get(clientId)?.sender,
}),
)
}),
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
+68 -196
View File
@@ -1,201 +1,73 @@
import { BrowserWindow, ipcMain } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
export * as Ipc from "./ipc"
import {
Ipc,
type FatalRendererError,
type IpcInvoke,
type IpcInvokeArgs,
type IpcInvokeResult,
type IpcSend,
type ServerReadyData,
} from "../shared/ipc-contract"
import { createFileCapabilities, openExternalURL, openLocalFileURL } from "./files"
import { setForceFocus } from "./native/debug"
import { runDesktopMenuAction } from "./native/menu-actions"
import { createDesktopStorage } from "./storage"
import {
getPinchZoomEnabled,
getWindowID,
setPinchZoomEnabled,
setTitlebar,
setWindowThemeReady,
updateTitlebar,
} from "./windows"
import type { UpdaterIpc } from "./updater"
import type { WslIpc } from "./wsl/ipc"
import { app, BrowserWindow, MessageChannelMain } from "electron"
import { Effect, Layer } from "effect"
import { RpcServer } from "effect/unstable/rpc"
import { DesktopRpcs } from "../shared/ipc-rpc"
import { IpcTransportPort } from "../shared/ipc-transport"
import { DesktopFiles, openExternalURL } from "./files"
import { appHandlers } from "./ipc-handlers/app"
import { eventHandlers } from "./ipc-handlers/events"
import { fileHandlers } from "./ipc-handlers/files"
import { menuHandlers } from "./ipc-handlers/menu"
import { storageHandlers } from "./ipc-handlers/storage"
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 { ApplicationLifecycle } from "./lifecycle"
import { createMenu, sendMenuCommand } from "./native/menu"
import { DesktopStorage } from "./storage"
import { Updater } from "./updater"
import { getLastFocusedWindow } from "./windows"
import { Wsl } from "./wsl/start"
type MaybePromise<Value> = Value | Promise<Value>
const services = Layer.mergeAll(DesktopFiles.layer, DesktopStorage.layer, Wsl.layer)
const handlers = Layer.mergeAll(
appHandlers,
storageHandlers,
fileHandlers,
windowHandlers,
menuHandlers,
updaterHandlers,
wslHandlers,
eventHandlers,
)
export const layer = RpcServer.layer(DesktopRpcs, { disableFatalDefects: true }).pipe(
Layer.provide(handlers),
Layer.provideMerge(IpcServerProtocolLive),
Layer.provideMerge(services),
)
function handle<Channel extends keyof IpcInvoke>(
channel: Channel,
listener: (event: IpcMainInvokeEvent, ...args: IpcInvokeArgs<Channel>) => MaybePromise<IpcInvokeResult<Channel>>,
) {
ipcMain.handle(channel, listener)
}
function on<Channel extends keyof IpcSend>(
channel: Channel,
listener: (event: IpcMainEvent, ...args: IpcSend[Channel]) => void,
) {
ipcMain.on(channel, listener)
}
type Deps = {
relaunch: () => void
awaitInitialization: () => Promise<ServerReadyData>
consumeInitialDeepLinks: () => Promise<string[]> | string[]
getDefaultServerUrl: () => Promise<string | null> | string | null
setDefaultServerUrl: (url: string | null) => Promise<void> | void
isFirstLaunchOnboardingPending: () => Promise<boolean> | boolean
finishFirstLaunchOnboarding: (createDefaultProject: boolean) => Promise<string | null> | string | null
checkAppExists: (appName: string) => Promise<boolean> | boolean
resolveAppPath: (appName: string) => Promise<string | null>
showUpdater: () => Promise<void> | void
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
setNativeTranslations: (bundle: DesktopNativeBundle) => void
}
export function registerIpcHandlers(deps: Deps) {
const files = createFileCapabilities()
const storage = createDesktopStorage()
handle(Ipc.app.awaitInitialization, () => deps.awaitInitialization())
handle(Ipc.app.consumeInitialDeepLinks, () => deps.consumeInitialDeepLinks())
handle(Ipc.app.getDefaultServerUrl, () => deps.getDefaultServerUrl())
handle(Ipc.app.setDefaultServerUrl, (_event, url) => deps.setDefaultServerUrl(url))
handle(Ipc.app.isFirstLaunchOnboardingPending, () => deps.isFirstLaunchOnboardingPending())
handle(Ipc.app.finishFirstLaunchOnboarding, (_event, createDefaultProject) =>
deps.finishFirstLaunchOnboarding(createDefaultProject),
)
handle(Ipc.app.checkAppExists, (_event, appName) => deps.checkAppExists(appName))
handle(Ipc.app.resolveAppPath, (_event, appName) => deps.resolveAppPath(appName))
handle(Ipc.app.setBackgroundColor, (_event, color) => deps.setBackgroundColor(color))
handle(Ipc.app.exportDebugLogs, () => deps.exportDebugLogs())
handle(Ipc.app.setForceFocus, (event, enabled) => setForceFocus(event.sender, enabled))
handle(Ipc.app.recordFatalRendererError, (_event, error) => deps.recordFatalRendererError(error))
handle(Ipc.app.setNativeTranslations, (event, value) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win || win.isDestroyed() || win.webContents !== event.sender || event.senderFrame !== event.sender.mainFrame) {
throw new Error("Invalid native translation sender")
}
const bundle = parseDesktopNativeBundle(value)
if (!bundle) throw new Error("Invalid native translation bundle")
deps.setNativeTranslations(bundle)
})
handle(Ipc.storage.get, (_event, name, key) => {
return storage.get(name, key)
})
handle(Ipc.storage.set, (_event, name, key, value) => storage.set(name, key, value))
handle(Ipc.storage.delete, (_event, name, key) => storage.deleteValue(name, key))
handle(Ipc.storage.clear, (_event, name) => storage.clear(name))
handle(Ipc.storage.keys, (_event, name) => storage.keys(name))
handle(Ipc.storage.length, (_event, name) => storage.length(name))
handle(Ipc.drafts.get, (_event, key) => storage.drafts.get(key))
handle(Ipc.drafts.set, (_event, key, value) => storage.drafts.set(key, value))
handle(Ipc.drafts.delete, (_event, key) => storage.drafts.set(key, null))
handle(Ipc.drafts.putBlob, (_event, data) => storage.drafts.putBlob(data))
handle(Ipc.drafts.getBlob, (_event, id) => storage.drafts.getBlob(id))
handle(Ipc.files.openDirectoryPicker, (_event, options) => files.openDirectoryPicker(options))
handle(Ipc.files.openFilePicker, (event, options) => files.openFilePicker(event.sender.id, options))
handle(Ipc.files.readPickedFile, (event, token, path) => files.readPickedFile(event.sender.id, token, path))
handle(Ipc.files.releasePickedFiles, (event, token) => files.releasePickedFiles(event.sender.id, token))
handle(Ipc.files.saveFilePicker, (_event, options) => files.saveFilePicker(options))
on(Ipc.files.openExternal, (_event, url) => openExternalURL(url))
on(Ipc.files.openLocalFile, (_event, url) => openLocalFileURL(url))
handle(Ipc.files.openPath, (_event, path, app) => files.openPath(path, app))
handle(Ipc.files.revealPath, (_event, path) => files.revealPath(path))
handle(Ipc.files.readClipboardImage, () => files.readClipboardImage())
handle(Ipc.window.getId, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error("Window not found")
const id = getWindowID(win)
if (!id) throw new Error("Window ID not found")
return id
})
handle(Ipc.window.themeReady, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) throw new Error("Window not found")
setWindowThemeReady(win)
})
handle(Ipc.window.getFocused, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFocused() ?? false
})
handle(Ipc.window.getFullscreen, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
return win?.isFullScreen() ?? false
})
handle(Ipc.window.setFocus, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.focus()
})
handle(Ipc.window.show, (event) => {
const win = BrowserWindow.fromWebContents(event.sender)
win?.show()
})
on(Ipc.app.relaunch, () => {
deps.relaunch()
})
handle(Ipc.window.getZoomFactor, (event) => event.sender.getZoomFactor())
handle(Ipc.window.setZoomFactor, (event, factor) => {
event.sender.setZoomFactor(factor)
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
updateTitlebar(win)
})
handle(Ipc.window.getPinchZoomEnabled, () => getPinchZoomEnabled())
handle(Ipc.window.setPinchZoomEnabled, (_event, enabled) => {
setPinchZoomEnabled(enabled)
})
handle(Ipc.window.setTitlebar, (event, theme) => {
const win = BrowserWindow.fromWebContents(event.sender)
if (!win) return
setTitlebar(win, theme)
})
handle(Ipc.menu.runAction, (event, action) => {
runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, {
checkForUpdates: () => void deps.showUpdater(),
relaunch: deps.relaunch,
export const registerIpcHandlers = Effect.gen(function* () {
const handoff = yield* IpcPortHandoff
const lifecycle = yield* ApplicationLifecycle.Service
const updater = yield* Updater.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const menu = {
trigger: (id: string) => {
const win = getLastFocusedWindow()
if (win) sendMenuCommand(win, id)
},
checkForUpdates: () => runFork(updater.show),
createWindow: lifecycle.createWindow,
openExternal: (url: string) => runFork(openExternalURL(url)),
relaunch: lifecycle.relaunch,
}
const wire = (_event: Electron.Event, win: BrowserWindow) => {
win.webContents.on("did-finish-load", () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
})
}
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
})
}
export function registerUpdaterIpcHandlers(updater: UpdaterIpc) {
handle(Ipc.updater.subscribe, (event) => updater.subscribe(event.sender))
handle(Ipc.updater.unsubscribe, (event) => updater.unsubscribe(event.sender.id))
handle(Ipc.updater.check, () => updater.check())
handle(Ipc.updater.install, () => updater.install())
}
export function registerWslInitialization(ready: Promise<void>) {
handle(Ipc.wsl.awaitInitialization, () => ready)
}
export function registerWslIpcHandlers(wsl: WslIpc) {
handle(Ipc.wsl.subscribe, (event) => wsl.subscribe(event.sender))
handle(Ipc.wsl.unsubscribe, (event) => wsl.unsubscribe(event.sender.id))
handle(Ipc.wsl.getState, () => wsl.getState())
handle(Ipc.wsl.probeRuntime, () => wsl.probeRuntime())
handle(Ipc.wsl.refreshDistros, () => wsl.refreshDistros())
handle(Ipc.wsl.installWsl, () => wsl.installWsl())
handle(Ipc.wsl.installDistro, (_event, value) => wsl.installDistro(value))
handle(Ipc.wsl.probeAddable, (_event, value) => wsl.probeAddable(value))
handle(Ipc.wsl.installOpencode, (_event, value) => wsl.installOpencode(value))
handle(Ipc.wsl.openTerminal, (_event, value) => wsl.openTerminal(value))
handle(Ipc.wsl.addServer, (_event, value) => wsl.addServer(value))
handle(Ipc.wsl.removeServer, (_event, value) => wsl.removeServer(value))
handle(Ipc.wsl.startServer, (_event, value) => wsl.startServer(value))
}
yield* Effect.addFinalizer(() => Effect.sync(() => app.off("browser-window-created", wire)))
return {
installMenu: () => createMenu(menu),
}
})
@@ -0,0 +1,38 @@
export * as DesktopInitialization from "./desktop-initialization"
import { app } from "electron"
import { Context, Effect, Layer } from "effect"
import { DesktopLogging } from "../native/logging"
import { getStore } from "../storage/store"
import {
loadProxyEnvironment,
preferApplicationEnvironment,
prepareApplicationEnvironment,
prepareDesktop,
} from "./environment"
import { initializeFirstLaunchOnboarding } from "./onboarding"
export interface Interface {
readonly version: string
readonly updaterStore: ReturnType<typeof getStore>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopInitialization") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const logging = yield* DesktopLogging.Service
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
yield* prepareApplicationEnvironment
yield* preferApplicationEnvironment
yield* loadProxyEnvironment
yield* Effect.promise(() => app.whenReady())
yield* logging.startNetwork
yield* prepareDesktop
return Service.of({
version: app.getVersion(),
updaterStore: getStore("opencode.updater"),
})
}),
)
@@ -1,18 +1,15 @@
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import http from "node:http"
import { homedir, tmpdir } from "node:os"
import { join } from "node:path"
import { getCACertificates, setDefaultCACertificates } from "node:tls"
import { app } from "electron"
import contextMenu from "electron-context-menu"
import { Effect } from "effect"
import { CHANNEL, VERSION } from "../constants"
import { initCrashReporter, initLogging, type DesktopLogger } from "../native/logging"
import { Effect, FileSystem, Path } from "effect"
import { CHANNEL } from "../constants"
import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { cleanupStoreFiles } from "../storage/cleanup"
import { registerRendererProtocol, setDockIcon } from "../windows"
import { initializeFirstLaunchOnboarding } from "./onboarding"
const appNames: Record<string, string> = {
dev: "OpenCode Dev",
@@ -27,7 +24,8 @@ const appIDs: Record<string, string> = {
const testOnboarding = process.env.OPENCODE_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
export function configureApplication() {
export const configureApplication = Effect.fn("Application.configure")(function* () {
const path = yield* Path.Path
contextMenu({ showSaveImageAs: true, showLookUpSelection: false, showSearchWithGoogle: false })
try {
process.chdir(homedir())
@@ -35,30 +33,18 @@ export function configureApplication() {
process.env.OPENCODE_DISABLE_EMBEDDED_WEB_UI = "true"
const appID = app.isPackaged ? appIDs[CHANNEL] : "ai.opencode.desktop.dev"
const testRoot = createTestRoot()
app.setName(app.isPackaged ? appNames[CHANNEL] : "OpenCode Dev")
app.setAppUserModelId(appID)
app.setPath("userData", testRoot ? join(testRoot, "desktop") : join(app.getPath("appData"), appID))
if (testRoot) app.setPath("sessionData", join(testRoot, "session"))
initializeFirstLaunchOnboarding(app.getPath("userData"))
const logger = initLogging()
initCrashReporter()
loadSystemCertificates(logger)
logger.log("app starting", {
version: VERSION,
packaged: app.isPackaged,
onboardingTest: testOnboarding,
})
loadProxyEnvironment(logger)
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
const features = app.commandLine.getSwitchValue("enable-features")
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
if (!app.isPackaged)
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
return logger
}
const testRoot = yield* createTestRoot()
app.setPath("userData", testRoot ? path.join(testRoot, "desktop") : path.join(app.getPath("appData"), appID))
if (testRoot) app.setPath("sessionData", path.join(testRoot, "session"))
})
export function acquireApplicationLock() {
if (app.requestSingleInstanceLock()) return true
@@ -66,73 +52,80 @@ export function acquireApplicationLock() {
return false
}
export function preferApplicationEnvironment(logger: DesktopLogger) {
export const prepareApplicationEnvironment = Effect.gen(function* () {
yield* loadSystemCertificates
yield* loadProxyEnvironment
})
export const preferApplicationEnvironment = Effect.gen(function* () {
const shell = process.platform === "win32" ? null : getUserShell()
const shellEnv = shell ? loadShellEnv(shell, logger) : null
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
Object.assign(process.env, {
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
const shellEnv = shell ? yield* loadShellEnv(shell) : null
yield* Effect.sync(() => {
if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME
Object.assign(process.env, {
...shellEnv,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
OPENCODE_CLIENT: "desktop",
})
})
}
})
export function prepareDesktop(logger: DesktopLogger) {
return Effect.gen(function* () {
yield* Effect.promise(() => cleanupStoreFiles(app.getPath("userData"))).pipe(
Effect.tap((result) =>
Effect.sync(() => {
if (result.deleted.length === 0) return
logger.log("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned })
}),
),
Effect.catch((error) => Effect.sync(() => logger.warn("failed to clean scoped store files", error))),
)
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
app.setAsDefaultProtocolClient("opencode")
registerRendererProtocol()
setDockIcon()
})
}
export const prepareDesktop = Effect.gen(function* () {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
yield* cleanupStoreFiles(app.getPath("userData")).pipe(
Effect.tap((result) =>
result.deleted.length === 0
? Effect.void
: Effect.logInfo("cleaned scoped store files", { count: result.deleted.length, scanned: result.scanned }),
),
Effect.catch((error) => Effect.logWarning("failed to clean scoped store files", { error })),
)
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
app.setAsDefaultProtocolClient("opencode")
yield* registerRendererProtocol()
setDockIcon(path, paths)
})
export function loadProxyEnvironment(logger: DesktopLogger) {
ensureLoopbackNoProxy()
try {
export const loadProxyEnvironment = Effect.gen(function* () {
yield* Effect.try(() => {
ensureLoopbackNoProxy()
// Electron 41.2 has a newer Node API than the current @types/node package.
const proxyAwareHttp = http as typeof http & { setGlobalProxyFromEnv(): void }
proxyAwareHttp.setGlobalProxyFromEnv()
} catch (error) {
logger.warn("failed to load proxy environment", error)
}
}
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load proxy environment", { error })))
})
function createTestRoot() {
const createTestRoot = Effect.fn("Application.createTestRoot")(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const root = testOnboarding
? join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
? path.join(tmpdir(), `opencode-onboarding-${randomUUID()}`)
: app.isPackaged
? undefined
: process.env.OPENCODE_DESKTOP_TEST_ROOT
if (!root) return undefined
if (testOnboarding) rmSync(root, { recursive: true, force: true })
;["data", "config", "cache", "state", "desktop", "session"].forEach((dir) =>
mkdirSync(join(root, dir), { recursive: true }),
if (testOnboarding) yield* fs.remove(root, { recursive: true, force: true })
yield* Effect.forEach(
["data", "config", "cache", "state", "desktop", "session"],
(dir) => fs.makeDirectory(path.join(root, dir), { recursive: true }),
{ discard: true },
)
if (testOnboarding) process.env.OPENCODE_DB = ":memory:"
process.env.XDG_DATA_HOME = join(root, "data")
process.env.XDG_CONFIG_HOME = join(root, "config")
process.env.XDG_CACHE_HOME = join(root, "cache")
process.env.XDG_STATE_HOME = join(root, "state")
process.env.XDG_DATA_HOME = path.join(root, "data")
process.env.XDG_CONFIG_HOME = path.join(root, "config")
process.env.XDG_CACHE_HOME = path.join(root, "cache")
process.env.XDG_STATE_HOME = path.join(root, "state")
return root
}
})
function loadSystemCertificates(logger: DesktopLogger) {
try {
const loadSystemCertificates = Effect.try({
try: () => {
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
} catch (error) {
logger.warn("failed to load system certificates", error)
}
}
},
catch: (error) => error,
}).pipe(Effect.catch((error) => Effect.logWarning("failed to load system certificates", { error })))
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
+149 -75
View File
@@ -1,80 +1,154 @@
export * as ApplicationLifecycle from "./index"
import { app, BrowserWindow } from "electron"
import type { Event } from "electron"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { writeLog, type DesktopLogger } from "../native/logging"
import { Context, Effect, Layer } from "effect"
import { DeepLinksOpened } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import { DesktopLogging, scoped } from "../native/logging"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, restoreMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { acquireApplicationLock, configureApplication } from "./environment"
import { Shutdown } from "./shutdown"
export function createApplicationLifecycle(logger: DesktopLogger) {
const pendingDeepLinks: string[] = []
const wsl = { stop: async () => {} }
const emitDeepLinks = (urls: string[]) => {
if (!urls.length) return
pendingDeepLinks.push(...urls)
const win = getLastFocusedWindow()
if (win) sendIpcEvent(win.webContents, Ipc.app.deepLink, urls)
}
const relaunch = () => {
setAppQuitting()
void wsl.stop().finally(() => {
app.relaunch()
app.quit()
})
}
app.on("second-instance", (_event: Event, argv: string[]) => {
const urls = argv.filter((arg) => arg.startsWith("opencode://"))
if (urls.length) {
logger.log("deep link received via second-instance", { urls })
emitDeepLinks(urls)
}
const win = getLastFocusedWindow()
if (!win) return
win.show()
win.focus()
})
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", () => {
setAppQuitting()
void wsl.stop()
})
app.on("will-quit", () => {
setAppQuitting()
void wsl.stop()
})
app.on("child-process-gone", (_event, details) => {
writeLog("utility", "child process gone", { details }, "error")
})
app.on("render-process-gone", (_event, webContents, details) => {
writeLog("window", "app render process gone", { url: safeWebContentsURL(webContents), details }, "error")
})
setRelaunchHandler(relaunch)
;(["SIGINT", "SIGTERM"] as const).forEach((signal) => {
process.on(signal, () => {
setAppQuitting()
void wsl.stop().finally(() => app.quit())
})
})
return {
relaunch,
prepareToRestart: () => wsl.stop(),
setWslShutdown(stop: () => Promise<void>) {
wsl.stop = stop
},
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
restoreWindows() {
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit()
})
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) restoreMainWindows()
})
return restoreMainWindows()
},
}
export interface Interface {
readonly relaunch: () => void
readonly prepareToRestart: Effect.Effect<void>
readonly consumeInitialDeepLinks: () => string[]
readonly createWindow: () => BrowserWindow
readonly restoreWindows: () => BrowserWindow[]
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/ApplicationLifecycle") {}
const runtime = Layer.effect(
Service,
Effect.gen(function* () {
const shutdown = yield* Shutdown.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const windows = yield* makeMainWindows()
const createWindow = windows.create
const restoreWindows = windows.restore
const pendingDeepLinks: string[] = []
let shutdownReady = false
const prepareToRestart = shutdown.run.pipe(Effect.ensuring(Effect.sync(() => (shutdownReady = true))))
const emitDeepLinks = (urls: string[]) => {
if (!urls.length) return
pendingDeepLinks.push(...urls)
const win = getLastFocusedWindow()
if (win) emitIpcEvent(win.webContents, new DeepLinksOpened({ urls }))
}
const relaunch = () => {
setAppQuitting()
runFork(
prepareToRestart.pipe(
Effect.ensuring(
Effect.sync(() => {
app.relaunch()
app.quit()
}),
),
),
)
}
const secondInstance = (_event: Event, argv: string[]) => {
const urls = argv.filter((arg) => arg.startsWith("opencode://"))
if (urls.length) {
runFork(Effect.logInfo("deep link received via second-instance", { urls }))
emitDeepLinks(urls)
}
const win = getLastFocusedWindow()
if (!win) return
win.show()
win.focus()
}
const openUrl = (event: Event, url: string) => {
event.preventDefault()
runFork(Effect.logInfo("deep link received via open-url", { url }))
emitDeepLinks([url])
}
const beforeQuit = (event: Event) => {
setAppQuitting()
if (shutdownReady) return
event.preventDefault()
runFork(prepareToRestart.pipe(Effect.ensuring(Effect.sync(() => app.quit()))))
}
const willQuit = () => {
setAppQuitting()
runFork(shutdown.run)
}
const childProcessGone = (_event: Event, details: Electron.Details) => {
runFork(scoped("utility", Effect.logError("child process gone", { details })))
}
const renderProcessGone = (
_event: Event,
webContents: Electron.WebContents,
details: Electron.RenderProcessGoneDetails,
) => {
runFork(
scoped("window", Effect.logError("app render process gone", { url: safeWebContentsURL(webContents), details })),
)
}
const signal = () => {
setAppQuitting()
runFork(prepareToRestart.pipe(Effect.ensuring(Effect.sync(() => app.quit()))))
}
const windowAllClosed = () => {
if (process.platform !== "darwin") app.quit()
}
const activate = () => {
if (BrowserWindow.getAllWindows().length === 0) restoreWindows()
}
const resetRelaunchHandler = setRelaunchHandler(relaunch)
let windowsWired = false
app.on("second-instance", secondInstance)
app.on("open-url", openUrl)
app.on("before-quit", beforeQuit)
app.on("will-quit", willQuit)
app.on("child-process-gone", childProcessGone)
app.on("render-process-gone", renderProcessGone)
process.on("SIGINT", signal)
process.on("SIGTERM", signal)
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
app.off("second-instance", secondInstance)
app.off("open-url", openUrl)
app.off("before-quit", beforeQuit)
app.off("will-quit", willQuit)
app.off("child-process-gone", childProcessGone)
app.off("render-process-gone", renderProcessGone)
app.off("window-all-closed", windowAllClosed)
app.off("activate", activate)
process.off("SIGINT", signal)
process.off("SIGTERM", signal)
resetRelaunchHandler()
}),
)
return Service.of({
relaunch,
prepareToRestart,
consumeInitialDeepLinks: () => pendingDeepLinks.splice(0),
createWindow,
restoreWindows: () => {
if (!windowsWired) {
windowsWired = true
app.on("window-all-closed", windowAllClosed)
app.on("activate", activate)
}
return restoreWindows()
},
})
}),
)
const platform = Layer.merge(DesktopLogging.layer, Shutdown.layer)
export const layer = Layer.unwrap(
Effect.gen(function* () {
if (!acquireApplicationLock()) return yield* Effect.interrupt
yield* configureApplication()
return runtime.pipe(Layer.provideMerge(platform))
}),
)
@@ -1,16 +1,23 @@
import { existsSync, readdirSync } from "node:fs"
import { mkdir } from "node:fs/promises"
import { join } from "node:path"
import { app } from "electron"
import { writeLog } from "../native/logging"
import { Effect, FileSystem, Option, Path } from "effect"
import { scoped } from "../native/logging"
import { hasExistingAppState } from "../storage/install-state"
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
const DEFAULT_PROJECT_DIR = "Default Project"
export function initializeFirstLaunchOnboarding(userDataPath: string) {
const entries = existsSync(userDataPath) ? readdirSync(userDataPath, { withFileTypes: true }) : []
export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize")(function* (userDataPath: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const names = (yield* fs.exists(userDataPath)) ? yield* fs.readDirectory(userDataPath) : []
const entries = yield* Effect.forEach(
names,
Effect.fnUntraced(function* (name) {
const info = yield* fs.stat(path.join(userDataPath, name)).pipe(Effect.option)
return { name, directory: Option.isSome(info) && info.value.type === "Directory" }
}),
)
const store = getStore()
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
if (typeof current === "boolean") return current
@@ -18,24 +25,29 @@ export function initializeFirstLaunchOnboarding(userDataPath: string) {
const complete = hasExistingAppState(entries)
store.set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, complete)
return complete
}
})
export function isFirstLaunchOnboardingPending() {
export const isFirstLaunchOnboardingPending = Effect.fn("Onboarding.isPending")(function* () {
const pending = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY) !== true
writeLog("onboarding", "first launch onboarding pending checked", { pending })
yield* scoped("onboarding", Effect.logInfo("first launch onboarding pending checked", { pending }))
return pending
}
})
export async function finishFirstLaunchOnboarding(createDefaultProject: boolean) {
if (!isFirstLaunchOnboardingPending()) {
writeLog("onboarding", "first launch onboarding already completed")
export const finishFirstLaunchOnboarding = Effect.fn("Onboarding.finish")(function* (createDefaultProject: boolean) {
if (!(yield* isFirstLaunchOnboardingPending())) {
yield* scoped("onboarding", Effect.logInfo("first launch onboarding already completed"))
return null
}
const defaultProject = createDefaultProject ? join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null
if (defaultProject) await mkdir(defaultProject, { recursive: true })
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const defaultProject = createDefaultProject ? path.join(app.getPath("documents"), DEFAULT_PROJECT_DIR) : null
if (defaultProject) yield* fs.makeDirectory(defaultProject, { recursive: true })
getStore().set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, true)
writeLog("onboarding", "first launch onboarding completed", { createDefaultProject, defaultProject })
yield* scoped(
"onboarding",
Effect.logInfo("first launch onboarding completed", { createDefaultProject, defaultProject }),
)
return defaultProject
}
})
@@ -0,0 +1,28 @@
export * as Shutdown from "./shutdown"
import { Context, Effect, Layer } from "effect"
export interface Interface {
readonly add: (effect: Effect.Effect<void>) => Effect.Effect<() => void>
readonly run: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/Shutdown") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const effects = new Set<Effect.Effect<void>>()
const run = yield* Effect.cached(
Effect.suspend(() => Effect.forEach(effects, (effect) => effect, { concurrency: "unbounded", discard: true })),
)
return Service.of({
add: (effect) =>
Effect.sync(() => {
effects.add(effect)
return () => effects.delete(effect)
}),
run,
})
}),
)
+205 -127
View File
@@ -1,8 +1,8 @@
import { MainLogger } from "electron-log"
export * as DesktopLogging from "./logging"
import log from "electron-log/main.js"
import { app, crashReporter, netLog, shell } from "electron"
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { Context, Effect, FileSystem, Layer, Logger, Option, Path, References } from "effect"
import { homedir } from "node:os"
import { VERSION } from "../constants"
@@ -16,97 +16,160 @@ let root = ""
let run = ""
let netLogPath: string | undefined
let logger: MainLogger
export const getLogger = () => logger
export type DesktopLogger = ReturnType<typeof initLogging>
export function initLogging() {
initRunDirectory()
log.transports.file.maxSize = 5 * 1024 * 1024
log.transports.file.resolvePathFn = (_vars, message) =>
join(
run,
`${safeLogName(message?.scope ?? (message?.variables?.processType === "renderer" ? "renderer" : "main"))}.log`,
)
log.initialize({ preload: false, spyRendererConsole: true })
initConsoleTransport()
cleanup()
return (logger = log)
export interface Interface {
readonly startNetwork: Effect.Effect<void>
readonly exportDebug: Effect.Effect<string>
}
export function initCrashReporter() {
const dir = join(app.getPath("userData"), "Crashpad")
mkdirSync(dir, { recursive: true })
app.setPath("crashDumps", dir)
crashReporter.start({ uploadToServer: false, compress: true })
writeLog("crash", "crash reporter started", { path: dir })
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopLogging") {}
async function startNetLog() {
if (netLog.currentlyLogging) return
netLogPath = join(run, "network.netlog")
await netLog.startLogging(netLogPath, { captureMode: "default", maxFileSize: NET_LOG_SIZE })
writeLog("network", "net log started", { path: netLogPath })
}
const serviceLayer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* initLogging(fs, path).pipe(Effect.orDie)
yield* initCrashReporter(fs, path).pipe(Effect.orDie)
yield* Effect.logInfo("app starting", {
version: VERSION,
packaged: app.isPackaged,
onboardingTest: process.env.OPENCODE_TEST_ONBOARDING === "1",
})
const exportDebug = exportDebugLogsEffect(fs, path).pipe(Effect.orDie)
return Service.of({
startNetwork: startNetLog(path).pipe(
Effect.catch((error) => Effect.logWarning("failed to start net log", { error })),
),
exportDebug,
})
}),
)
export function startNetworkLogging() {
return startNetLog().catch((error) => logger.warn("failed to start net log", error))
}
export async function exportDebugLogs() {
const restartNetLog = netLog.currentlyLogging
if (restartNetLog) {
await netLog.stopLogging().catch((error) => writeLog("network", "failed to stop net log", { error }))
}
const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
const nativeLogger = Logger.make((options) => {
try {
writeLog("main", "exporting debug logs", { output })
await writeZip(output, [
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(), null, 2)) },
...collect(root, "desktop"),
...serverLogRoots().flatMap((dir, i) => collect(dir, `server-${i + 1}`)),
...collect(app.getPath("crashDumps"), "crashpad"),
])
shell.showItemInFolder(output)
return output
} finally {
if (restartNetLog) {
await startNetLog().catch((error) => writeLog("network", "failed to restart net log", { error }))
if (!run) return
const entry = Logger.formatStructured.log(options)
const scope = typeof entry.annotations.scope === "string" ? entry.annotations.scope : "main"
const annotations = Object.fromEntries(Object.entries(entry.annotations).filter(([key]) => key !== "scope"))
const context = {
...(Object.keys(annotations).length === 0 ? {} : { annotations }),
...(Object.keys(entry.spans).length === 0 ? {} : { spans: entry.spans }),
...(entry.cause === undefined ? {} : { cause: entry.cause }),
}
const messages = Array.isArray(options.message) ? options.message : [options.message]
log.scope(safeLogName(scope))[methods[options.logLevel]](
...messages,
...(Object.keys(context).length === 0 ? [] : [context]),
)
} catch {
// Logging must not interrupt application work.
}
})
const methods = {
All: "silly",
Trace: "silly",
Debug: "debug",
Info: "info",
Warn: "warn",
Error: "error",
Fatal: "error",
None: "silly",
} as const
const nativeLoggerLayer = Layer.merge(
Logger.layer([nativeLogger], { mergeWithExisting: false }),
Layer.succeed(References.MinimumLogLevel, "All"),
)
export const layer = serviceLayer.pipe(Layer.provideMerge(nativeLoggerLayer))
function initLogging(fs: FileSystem.FileSystem, path: Path.Path) {
return Effect.gen(function* () {
yield* initRunDirectory(fs, path)
yield* Effect.sync(() => {
log.transports.file.maxSize = 5 * 1024 * 1024
log.transports.file.resolvePathFn = (_vars, message) =>
path.join(
run,
`${safeLogName(message?.scope ?? (message?.variables?.processType === "renderer" ? "renderer" : "main"))}.log`,
)
log.initialize({ preload: false, spyRendererConsole: true })
initConsoleTransport()
})
yield* cleanup(fs, path)
})
}
export function writeLog(
name: string,
message: string,
extra?: Record<string, unknown>,
level: "info" | "warn" | "error" = "info",
) {
if (!run) return
const scoped = log.scope(safeLogName(name))
if (extra !== undefined) {
scoped[level](message, extra)
return
}
scoped[level](message)
function initCrashReporter(fs: FileSystem.FileSystem, path: Path.Path) {
return Effect.gen(function* () {
const dir = path.join(app.getPath("userData"), "Crashpad")
yield* fs.makeDirectory(dir, { recursive: true })
yield* Effect.sync(() => {
app.setPath("crashDumps", dir)
crashReporter.start({ uploadToServer: false, compress: true })
})
yield* scoped("crash", Effect.logInfo("crash reporter started", { path: dir }))
})
}
export function tail(): string {
try {
function startNetLog(path: Path.Path) {
if (netLog.currentlyLogging) return Effect.void
const target = path.join(run, "network.netlog")
netLogPath = target
return Effect.tryPromise(() => netLog.startLogging(target, { captureMode: "default", maxFileSize: NET_LOG_SIZE })).pipe(
Effect.tap(() => scoped("network", Effect.logInfo("net log started", { path: target }))),
)
}
function exportDebugLogsEffect(fs: FileSystem.FileSystem, path: Path.Path) {
return Effect.gen(function* () {
const restartNetLog = netLog.currentlyLogging
if (restartNetLog) {
yield* Effect.tryPromise(() => netLog.stopLogging()).pipe(
Effect.catch((error) => scoped("network", Effect.logWarning("failed to stop net log", { error }))),
)
}
const output = path.join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
return yield* Effect.gen(function* () {
yield* Effect.logInfo("exporting debug logs", { output })
yield* writeZip(fs, output, [
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(path), null, 2)) },
...(yield* collect(fs, path, root, "desktop")),
...(yield* Effect.forEach(serverLogRoots(path), (dir, i) => collect(fs, path, dir, `server-${i + 1}`))).flat(),
...(yield* collect(fs, path, app.getPath("crashDumps"), "crashpad")),
])
yield* Effect.sync(() => shell.showItemInFolder(output))
return output
}).pipe(
Effect.ensuring(
restartNetLog
? startNetLog(path).pipe(
Effect.catch((error) =>
scoped("network", Effect.logWarning("failed to restart net log", { error })),
),
)
: Effect.void,
),
)
})
}
export const tail = Effect.fn("DesktopLogging.tail")(function* () {
const fs = yield* FileSystem.FileSystem
return yield* Effect.gen(function* () {
const path = log.transports.file.getFile().path
const contents = readFileSync(path, "utf8")
const contents = yield* fs.readFileString(path)
const lines = contents.split("\n")
return lines.slice(Math.max(0, lines.length - TAIL_LINES)).join("\n")
} catch {
return ""
}
}
}).pipe(Effect.catch(() => Effect.succeed("")))
})
function initRunDirectory() {
root = join(app.getPath("userData"), "logs")
run = join(root, stamp())
mkdirSync(run, { recursive: true })
function initRunDirectory(fs: FileSystem.FileSystem, path: Path.Path) {
root = path.join(app.getPath("userData"), "logs")
run = path.join(root, stamp())
return fs.makeDirectory(run, { recursive: true })
}
function stamp() {
@@ -120,22 +183,27 @@ function safeLogName(name: string) {
return name.replace(/[^a-z0-9_.-]/gi, "_") || "main"
}
function cleanup() {
const dir = root || dirname(log.transports.file.getFile().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.mtimeMs < cutoff) rmSync(file, { recursive: true, force: true })
} catch {
continue
}
}
function cleanup(fs: FileSystem.FileSystem, path: Path.Path) {
return Effect.gen(function* () {
const dir = root || path.dirname(log.transports.file.getFile().path)
const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000
const entries = yield* fs.readDirectory(dir)
yield* Effect.forEach(
entries,
(entry) =>
Effect.gen(function* () {
const file = path.join(dir, entry)
const info = yield* fs.stat(file)
if (Option.getOrElse(info.mtime, () => new Date(0)).getTime() < cutoff) {
yield* fs.remove(file, { recursive: true, force: true })
}
}).pipe(Effect.catch(() => Effect.void)),
{ discard: true },
)
})
}
function manifest() {
function manifest(path: Path.Path) {
return {
generated: new Date().toISOString(),
version: VERSION,
@@ -149,49 +217,55 @@ function manifest() {
logs: root,
currentRun: run,
crashDumps: app.getPath("crashDumps"),
serverLogs: serverLogRoots(),
serverLogs: serverLogRoots(path),
netLog: netLogPath,
}
}
function serverLogRoots() {
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
return [...new Set([join(xdgData, "opencode", "log"), join(app.getPath("userData"), "opencode", "log")])]
function serverLogRoots(path: Path.Path) {
const xdgData = process.env.XDG_DATA_HOME || path.join(homedir(), ".local", "share")
return [
...new Set([path.join(xdgData, "opencode", "log"), path.join(app.getPath("userData"), "opencode", "log")]),
]
}
type Entry = { name: string; path?: string; data?: Buffer }
type Entry = { name: string; path: string } | { name: string; data: Uint8Array }
function collect(dir: string, prefix: string): Entry[] {
if (!existsSync(dir)) return []
const cutoff = Date.now() - EXPORT_WINDOW
const result: Entry[] = []
const walk = (current: string) => {
for (const entry of readdirSync(current)) {
const file = join(current, entry)
const info = statSync(file)
if (info.isDirectory()) {
walk(file)
continue
}
if (info.mtimeMs < cutoff) continue
if (info.size > MAX_EXPORT_FILE_SIZE) continue
if (file.endsWith(".heapsnapshot")) continue
result.push({ name: join(prefix, file.slice(dir.length + 1)).replace(/\\/g, "/"), path: file })
}
}
walk(dir)
return result
function collect(fs: FileSystem.FileSystem, path: Path.Path, dir: string, prefix: string) {
return Effect.gen(function* () {
if (!(yield* fs.exists(dir).pipe(Effect.orElseSucceed(() => false)))) return []
const cutoff = Date.now() - EXPORT_WINDOW
const entries = yield* fs.readDirectory(dir, { recursive: true })
return (yield* Effect.forEach(entries, (entry) =>
Effect.gen(function* () {
const file = path.join(dir, entry)
const info = yield* fs.stat(file)
if (info.type === "Directory") return null
if (Option.getOrElse(info.mtime, () => new Date(0)).getTime() < cutoff) return null
if (info.size > FileSystem.Size(MAX_EXPORT_FILE_SIZE)) return null
if (file.endsWith(".heapsnapshot")) return null
return { name: path.join(prefix, entry).replace(/\\/g, "/"), path: file }
}),
)).filter((entry) => entry !== null)
})
}
async function writeZip(output: string, entries: Entry[]) {
const { BlobReader, BlobWriter, ZipWriter } = await import("@zip.js/zip.js")
const writer = new ZipWriter(new BlobWriter("application/zip"))
for (const entry of entries) {
const data = entry.data ?? readFileSync(entry.path!)
await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)])))
}
const zip = await writer.close()
writeFileSync(output, Buffer.from(await zip.arrayBuffer()))
function writeZip(fs: FileSystem.FileSystem, output: string, entries: Entry[]) {
return Effect.gen(function* () {
const { BlobReader, BlobWriter, ZipWriter } = yield* Effect.promise(() => import("@zip.js/zip.js"))
const writer = new ZipWriter(new BlobWriter("application/zip"))
yield* Effect.forEach(
entries,
(entry) =>
Effect.gen(function* () {
const data = "data" in entry ? entry.data : yield* fs.readFile(entry.path)
yield* Effect.tryPromise(() => writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)]))))
}),
{ concurrency: 1, discard: true },
)
const zip = yield* Effect.tryPromise(() => writer.close())
yield* fs.writeFile(output, new Uint8Array(yield* Effect.tryPromise(() => zip.arrayBuffer())))
})
}
function initConsoleTransport() {
@@ -214,3 +288,7 @@ function initConsoleTransport() {
function isBrokenPipe(err: unknown) {
return typeof err === "object" && err !== null && "code" in err && err.code === "EPIPE"
}
export function scoped(name: string, effect: Effect.Effect<void>) {
return effect.pipe(Effect.annotateLogs("scope", name))
}
@@ -1,9 +1,10 @@
import { BrowserWindow } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import { createMainWindow, updateTitlebar } from "../windows"
import { updateTitlebar } from "../windows"
export type DesktopMenuActionHandlers = Partial<{
checkForUpdates: () => void
createWindow: () => void
relaunch: () => void
}>
@@ -20,7 +21,7 @@ export function runDesktopMenuAction(
handlers.relaunch?.()
return
case "window.new":
createMainWindow()
handlers.createWindow?.()
return
case "window.close":
win?.close()
+7 -4
View File
@@ -6,16 +6,18 @@ import {
type DesktopMenuEntry,
type DesktopMenuRole,
} from "@opencode-ai/app/desktop-menu"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { MenuCommandTriggered } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import { UPDATER_ENABLED } from "../constants"
import { openExternalURL } from "../files"
import { runDesktopMenuAction } from "./menu-actions"
import { nativeT } from "./translations"
type Deps = {
trigger: (id: string) => void
checkForUpdates: () => void
createWindow: () => void
openExternal: (url: string) => void
relaunch: () => void
}
@@ -36,7 +38,7 @@ export function createMenu(deps: Deps) {
}
export function sendMenuCommand(win: BrowserWindow, id: string) {
sendIpcEvent(win.webContents, Ipc.menu.command, id)
emitIpcEvent(win.webContents, new MenuCommandTriggered({ id }))
}
function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions {
@@ -58,12 +60,13 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt
item.click = () =>
runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, {
checkForUpdates: deps.checkForUpdates,
createWindow: deps.createWindow,
relaunch: deps.relaunch,
})
}
if (entry.href) {
const href = entry.href
item.click = () => openExternalURL(href)
item.click = () => deps.openExternal(href)
}
return item
+18 -6
View File
@@ -1,7 +1,19 @@
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
export * as DesktopPaths from "./paths"
export const mainBundleRoot = dirname(fileURLToPath(import.meta.url))
export const developmentResourcesRoot = join(mainBundleRoot, "../../resources")
export const preloadPath = join(mainBundleRoot, "../preload/index.js")
export const rendererRoot = join(mainBundleRoot, "../renderer")
import { Effect, Path } from "effect"
export interface Resolved {
readonly developmentResourcesRoot: string
readonly preloadPath: string
readonly rendererRoot: string
}
export const resolve = Effect.gen(function* () {
const path = yield* Path.Path
const root = path.dirname(yield* path.fromFileUrl(new URL(import.meta.url)))
return {
developmentResourcesRoot: path.join(root, "../../resources"),
preloadPath: path.join(root, "../preload/index.js"),
rendererRoot: path.join(root, "../renderer"),
} satisfies Resolved
}).pipe(Effect.orDie)
@@ -1,147 +1,67 @@
import { Service } from "@opencode-ai/client/service"
import { execFile } from "node:child_process"
import { existsSync } from "node:fs"
import { chmod, copyFile, mkdir, readdir, rename, rm } from "node:fs/promises"
import { dirname, join } from "node:path"
import { promisify } from "node:util"
import { app } from "electron"
import { parseCliVersion } from "./cli-version"
import { developmentResourcesRoot } from "../paths"
import { Context, Effect, Exit, Layer, Path } from "effect"
import type { ServerReadyData } from "../../shared/ipc-contract"
import { cleanStages, DesktopCli } from "./desktop-cli"
const execFileAsync = promisify(execFile)
type Logger = {
log(message: string, meta?: Record<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): void
export * as BackgroundService from "./background-service"
export interface Interface {
readonly connection: Effect.Effect<ServerReadyData>
}
export async function startBackgroundCli(logger: Logger) {
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/BackgroundService") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const result = yield* start().pipe(Effect.exit)
return Service.of({
connection: Exit.isSuccess(result)
? Effect.succeed(result.value)
: Effect.failCause(result.cause).pipe(Effect.orDie),
})
}),
)
const start = Effect.fn("BackgroundService.start")(function* () {
yield* Effect.logInfo("starting v2 background service")
const path = yield* Path.Path
const desktopCli = yield* DesktopCli.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
const development = !app.isPackaged && process.env.OPENCODE_DESKTOP_CLI_DEV
const developmentVersion = process.env.OPENCODE_VERSION ?? "local"
const cli = development
? {
version: developmentVersion,
command: [
"bun",
"run",
"--cwd",
development,
`--define=OPENCODE_VERSION=${JSON.stringify(developmentVersion)}`,
"src/index.ts",
],
binary: undefined,
}
: await resolveBundledCli(isolated, logger)
const cli = yield* desktopCli.resolve
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const service = await Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version: cli.version,
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
})
const client = yield* Effect.promise(() => import("@opencode-ai/client/service"))
const service = yield* Effect.tryPromise(() =>
client.Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
? path.join(app.getPath("userData"), "opencode", "service-local.json")
: undefined,
version: cli.version,
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
onStart: (reason, previousVersion) =>
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
}),
)
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
const url = new URL(service.url)
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
logger.log("v2 CLI background service ready", {
yield* Effect.logInfo("v2 CLI background service ready", {
username: service.auth.username,
version: cli.version,
...endpoint(url.origin),
})
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
if (isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
return {
url: url.origin,
username: service.auth.username,
password: service.auth.password,
version: cli.version,
wslBuild:
app.isPackaged || !process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD || !process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT
? undefined
: {
script: process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD,
output: process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT,
},
}
}
async function resolveBundledCli(isolated: boolean, logger: Logger) {
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(developmentResourcesRoot, isolated ? developmentExecutableName() : executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseCliVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
return { version, binary, command: [binary] }
}
async function cleanCliStages(binary: string, logger: Logger) {
const current = dirname(binary)
const root = dirname(current)
await Promise.all(
(await readdir(root, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && join(root, entry.name) !== current)
.map((entry) =>
rm(join(root, entry.name), { recursive: true, force: true }).catch((error) =>
logger.error("failed to clean staged v2 CLI", { path: join(root, entry.name), error }),
),
),
)
}
async function installCli(source: string, version: string, logger: Logger) {
const directory = join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
const destination = join(directory, executableName())
if (existsSync(destination)) {
logger.log("v2 CLI staged executable reused", { path: destination, version })
return destination
}
const temp = destination + `.${process.pid}.tmp`
await mkdir(directory, { recursive: true })
await copyFile(source, temp)
if (process.platform !== "win32") await chmod(temp, 0o755)
await rename(temp, destination).catch(async (error) => {
await rm(temp, { force: true })
throw error
})
logger.log("v2 CLI executable staged", { source, path: destination, version })
return destination
}
async function run(binary: string, args: string[], logger: Logger) {
logger.log("v2 CLI command started", { binary, args })
return execFileAsync(binary, args, { windowsHide: true }).then(
(result) => {
const stdout = result.stdout.trim()
const stderr = result.stderr.trim()
logger.log("v2 CLI command completed", { args, stdout, stderr })
return stdout
},
(error: unknown) => {
const output = error as { stdout?: string; stderr?: string }
logger.error("v2 CLI command failed", {
args,
error: error instanceof Error ? error.message : String(error),
stdout: output.stdout?.trim() ?? "",
stderr: output.stderr?.trim() ?? "",
})
throw error
},
)
}
} satisfies ServerReadyData
})
function endpoint(url: string | undefined) {
if (!url || !URL.canParse(url)) return {}
const parsed = new URL(url)
return { url, hostname: parsed.hostname, port: parsed.port }
}
function executableName() {
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
}
function developmentExecutableName() {
return process.platform === "win32" ? "opencode-cli-dev.exe" : "opencode-cli-dev"
}
@@ -0,0 +1,143 @@
export * as DesktopCli from "./desktop-cli"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { DesktopPaths } from "../paths"
import { parseCliVersion } from "./cli-version"
const execFileAsync = promisify(execFile)
export interface Resolved {
readonly version: string
readonly command: readonly string[]
readonly binary?: string
readonly wslBuild?: { readonly script: string; readonly output: string }
}
export interface Interface {
readonly resolve: Effect.Effect<Resolved>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopCli") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const resolve = yield* Effect.cached(
make().pipe(Effect.provide(yield* Effect.context<FileSystem.FileSystem | Path.Path>()), Effect.orDie),
)
return Service.of({ resolve })
}),
)
const make = Effect.fn("DesktopCli.resolve")(function* () {
const development = !app.isPackaged && process.env.OPENCODE_DESKTOP_CLI_DEV
const version = process.env.OPENCODE_VERSION ?? "local"
const cli = development
? {
version,
command: [
"bun",
"run",
"--cwd",
development,
`--define=OPENCODE_VERSION=${JSON.stringify(version)}`,
"src/index.ts",
],
binary: undefined,
}
: yield* resolveBundledCli(!app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1")
return {
...cli,
wslBuild:
app.isPackaged || !process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD || !process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT
? undefined
: {
script: process.env.OPENCODE_DESKTOP_WSL_CLI_BUILD,
output: process.env.OPENCODE_DESKTOP_WSL_CLI_OUTPUT,
},
} satisfies Resolved
})
const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isolated: boolean) {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const bundled = app.isPackaged
? path.join(process.resourcesPath, executableName())
: path.join(paths.developmentResourcesRoot, isolated ? developmentExecutableName() : executableName())
yield* Effect.logInfo("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseCliVersion(yield* run(bundled, ["--version"]))
const binary = app.isPackaged || isolated ? yield* installCli(bundled, version) : bundled
return { version, binary, command: [binary] }
})
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const current = path.dirname(binary)
const root = path.dirname(current)
const entries = yield* fs.readDirectory(root)
yield* Effect.forEach(
entries,
Effect.fnUntraced(function* (entry) {
const target = path.join(root, entry)
if (target === current) return
const stat = yield* fs.stat(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (stat?.type !== "Directory") return
yield* fs
.remove(target, { recursive: true, force: true })
.pipe(Effect.catch((error) => Effect.logError("failed to clean staged v2 CLI", { path: target, error })))
}),
{ concurrency: "unbounded" },
)
})
const installCli = Effect.fn("DesktopCli.install")(function* (source: string, version: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const directory = path.join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
const destination = path.join(directory, executableName())
if (yield* fs.exists(destination)) {
yield* Effect.logInfo("v2 CLI staged executable reused", { path: destination, version })
return destination
}
const temp = destination + `.${process.pid}.tmp`
yield* fs.makeDirectory(directory, { recursive: true })
yield* fs.copyFile(source, temp)
if (process.platform !== "win32") yield* fs.chmod(temp, 0o755)
yield* fs
.rename(temp, destination)
.pipe(Effect.catch((error) => fs.remove(temp, { force: true }).pipe(Effect.andThen(Effect.fail(error)))))
yield* Effect.logInfo("v2 CLI executable staged", { source, path: destination, version })
return destination
})
const run = Effect.fn("DesktopCli.run")(function* (binary: string, args: string[]) {
yield* Effect.logInfo("v2 CLI command started", { binary, args })
const result = yield* Effect.tryPromise(() => execFileAsync(binary, args, { windowsHide: true })).pipe(
Effect.tapError((error) => {
const output = error as { stdout?: string; stderr?: string }
return Effect.logError("v2 CLI command failed", {
args,
error: error instanceof Error ? error.message : String(error),
stdout: output.stdout?.trim() ?? "",
stderr: output.stderr?.trim() ?? "",
})
}),
)
const stdout = result.stdout.trim()
const stderr = result.stderr.trim()
yield* Effect.logInfo("v2 CLI command completed", { args, stdout, stderr })
return stdout
})
function executableName() {
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
}
function developmentExecutableName() {
return process.platform === "win32" ? "opencode-cli-dev.exe" : "opencode-cli-dev"
}
@@ -1,37 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
import { forwardInitializationFailure } from "./initialization"
describe("desktop initialization", () => {
const failure = new Error("sidecar startup failed")
const expectFailure = (exit: Exit.Exit<unknown, unknown>) => {
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) return
expect(Cause.squash(exit.cause)).toBe(failure)
}
test("forwards loading task failures before renderer initialization", () => {
const exit = Effect.runSync(
Effect.gen(function* () {
const initialization = yield* Deferred.make<never, unknown>()
yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit)
return yield* Deferred.await(initialization).pipe(Effect.exit)
}),
)
expectFailure(exit)
})
test("forwards loading task failures while renderer initialization waits", () => {
const exit = Effect.runSync(
Effect.gen(function* () {
const initialization = yield* Deferred.make<never, unknown>()
const waiting = yield* Deferred.await(initialization).pipe(Effect.exit, Effect.forkChild)
yield* forwardInitializationFailure(initialization)(Effect.die(failure)).pipe(Effect.exit)
return yield* Fiber.join(waiting)
}),
)
expectFailure(exit)
})
})
@@ -1,6 +0,0 @@
import { Deferred, Effect } from "effect"
export function forwardInitializationFailure<A>(initialization: Deferred.Deferred<A, unknown>) {
return <B, E, R>(effect: Effect.Effect<B, E, R>) =>
effect.pipe(Effect.tapCause((cause) => Deferred.failCause(initialization, cause)))
}
@@ -1,4 +1,6 @@
import { describe, expect, test } from "bun:test"
import { NodePath } from "@effect/platform-node"
import { Effect } from "effect"
import { isNushell, mergeShellEnv, parseShellEnv, resolveUserShell } from "./shell-env"
@@ -42,9 +44,10 @@ describe("shell env", () => {
})
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)
const check = (shell: string) => Effect.runSync(isNushell(shell).pipe(Effect.provide(NodePath.layer)))
expect(check("nu")).toBe(true)
expect(check("/opt/homebrew/bin/nu")).toBe(true)
expect(check("C:\\Program Files\\nu.exe")).toBe(true)
expect(check("/bin/zsh")).toBe(false)
})
})
+25 -28
View File
@@ -1,14 +1,10 @@
import { spawnSync } from "node:child_process"
import { userInfo } from "node:os"
import { basename } from "node:path"
import { Effect, Path } from "effect"
const TIMEOUT = 5_000
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
type ShellEnvLogger = {
log: (message: string) => void
}
export function resolveUserShell(envShell: string | undefined, loginShell: string | null | undefined) {
const resolvedLoginShell = loginShell && loginShell !== "unknown" ? loginShell : undefined
return envShell || resolvedLoginShell || "/bin/sh"
@@ -33,7 +29,7 @@ export function parseShellEnv(out: Buffer) {
return env
}
function probe(shell: string, mode: "-il" | "-l"): Probe {
const probe = Effect.fn("ShellEnv.probe")(function* (shell: string, mode: "-il" | "-l") {
const out = spawnSync(shell, [mode, "-c", "env -0"], {
stdio: ["ignore", "pipe", "ignore"],
timeout: TIMEOUT,
@@ -42,56 +38,57 @@ function probe(shell: string, mode: "-il" | "-l"): Probe {
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 (err.code === "ETIMEDOUT") return { type: "Timeout" } satisfies Probe
yield* Effect.logWarning(`[server] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
return { type: "Unavailable" } satisfies Probe
}
if (out.status !== 0) {
console.log(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
return { type: "Unavailable" }
yield* Effect.logWarning(`[server] Shell env probe exited with non-zero status for ${shell} ${mode}`)
return { type: "Unavailable" } satisfies Probe
}
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" }
yield* Effect.logWarning(`[server] Shell env probe returned empty env for ${shell} ${mode}`)
return { type: "Unavailable" } satisfies Probe
}
return { type: "Loaded", value: env }
}
return { type: "Loaded", value: env } satisfies Probe
})
export function isNushell(shell: string) {
const name = basename(shell).toLowerCase()
export const isNushell = Effect.fn("ShellEnv.isNushell")(function* (shell: string) {
const path = yield* Path.Path
const name = path.basename(shell).toLowerCase()
const raw = shell.toLowerCase()
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
}
})
export function loadShellEnv(shell: string, logger: ShellEnvLogger) {
if (isNushell(shell)) {
logger.log(`[server] Skipping shell env probe for nushell: ${shell}`)
export const loadShellEnv = Effect.fn("ShellEnv.load")(function* (shell: string) {
if (yield* isNushell(shell)) {
yield* Effect.logInfo(`[server] Skipping shell env probe for nushell: ${shell}`)
return null
}
const interactive = probe(shell, "-il")
const interactive = yield* probe(shell, "-il")
if (interactive.type === "Loaded") {
logger.log(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
yield* Effect.logInfo(`[server] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
return interactive.value
}
if (interactive.type === "Timeout") {
logger.log(`[server] Interactive shell env probe timed out: ${shell}`)
yield* Effect.logInfo(`[server] Interactive shell env probe timed out: ${shell}`)
return null
}
const login = probe(shell, "-l")
const login = yield* probe(shell, "-l")
if (login.type === "Loaded") {
logger.log(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
yield* Effect.logInfo(`[server] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
return login.value
}
logger.log(`[server] Falling back to app environment: ${shell}`)
yield* Effect.logInfo(`[server] Falling back to app environment: ${shell}`)
return null
}
})
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
return {
+118 -71
View File
@@ -1,93 +1,140 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtemp, readdir, rm, utimes, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { Effect, FileSystem, Layer, Path } from "effect"
import { cleanupStoreFiles, deleteStoreFileIfEmpty } from "./cleanup"
const roots: string[] = []
const platform = Layer.merge(NodeFileSystem.layer, NodePath.layer)
const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) =>
Effect.runPromise(effect.pipe(Effect.provide(platform)))
async function tempRoot() {
const root = await mkdtemp(join(tmpdir(), "opencode-store-cleanup-"))
const tempRoot = Effect.fn("StorageTest.tempRoot")(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* fs.makeTempDirectory({ directory: tmpdir(), prefix: "opencode-store-cleanup-" })
roots.push(root)
return root
}
async function writeStore(root: string, name: string, value: string, modified: Date) {
await writeFile(join(root, name), value)
await utimes(join(root, name), modified, modified)
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
const writeStore = Effect.fn("StorageTest.writeStore")(function* (
root: string,
name: string,
value: string,
modified: Date,
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* fs.writeFileString(path.join(root, name), value)
yield* fs.utimes(path.join(root, name), modified, modified)
})
afterEach(() =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
yield* Effect.forEach(roots.splice(0), (root) => fs.remove(root, { recursive: true, force: true }), {
concurrency: "unbounded",
discard: true,
})
}),
),
)
describe("store cleanup", () => {
test("removes empty scoped stores and leaves global stores alone", async () => {
const root = await tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
await writeStore(root, "opencode.draft.empty.dat", "{}", now)
await writeStore(root, "opencode.workspace.empty.dat", "{\n}", now)
await writeStore(root, "opencode.global.dat", "{}", now)
await writeStore(root, "opencode.workspace.empty.dat.json", "{}", now)
test("removes empty scoped stores and leaves global stores alone", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* writeStore(root, "opencode.draft.empty.dat", "{}", now)
yield* writeStore(root, "opencode.workspace.empty.dat", "{\n}", now)
yield* writeStore(root, "opencode.global.dat", "{}", now)
yield* writeStore(root, "opencode.workspace.empty.dat.json", "{}", now)
const result = await cleanupStoreFiles(root, now.getTime())
const result = yield* cleanupStoreFiles(root, now.getTime())
expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"])
expect((await readdir(root)).sort()).toEqual(["opencode.global.dat", "opencode.workspace.empty.dat.json"])
})
expect(result.deleted.sort()).toEqual(["opencode.draft.empty.dat", "opencode.workspace.empty.dat"])
expect((yield* fs.readDirectory(root)).sort()).toEqual([
"opencode.global.dat",
"opencode.workspace.empty.dat.json",
])
}),
),
)
test("removes stale drafts by age without removing non-empty workspace stores", async () => {
const root = await tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
await writeStore(root, "opencode.draft.old.dat", '{"draft:prompt":"hello"}', new Date("2026-05-01T00:00:00.000Z"))
await writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now)
await writeStore(
root,
"opencode.workspace.old.dat",
'{"workspace:layout":"wide"}',
new Date("2025-01-01T00:00:00.000Z"),
)
await writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now)
const result = await cleanupStoreFiles(root, now.getTime())
expect(result.deleted).toEqual(["opencode.draft.old.dat"])
expect((await readdir(root)).sort()).toEqual([
"opencode.draft.recent.dat",
"opencode.workspace.old.dat",
"opencode.workspace.recent.dat",
])
})
test("caps scoped stores by recency", async () => {
const root = await tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
await Promise.all(
Array.from({ length: 102 }, (_, index) =>
writeStore(
test("removes stale drafts by age without removing non-empty workspace stores", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* writeStore(
root,
`opencode.draft.${index}.dat`,
"opencode.draft.old.dat",
'{"draft:prompt":"hello"}',
new Date(now.getTime() - index * 1000),
),
),
)
new Date("2026-05-01T00:00:00.000Z"),
)
yield* writeStore(root, "opencode.draft.recent.dat", '{"draft:prompt":"hello"}', now)
yield* writeStore(
root,
"opencode.workspace.old.dat",
'{"workspace:layout":"wide"}',
new Date("2025-01-01T00:00:00.000Z"),
)
yield* writeStore(root, "opencode.workspace.recent.dat", '{"workspace:layout":"wide"}', now)
const result = await cleanupStoreFiles(root, now.getTime())
const result = yield* cleanupStoreFiles(root, now.getTime())
const remaining = await readdir(root)
expect(result.deleted).toEqual(["opencode.draft.old.dat"])
expect((yield* fs.readDirectory(root)).sort()).toEqual([
"opencode.draft.recent.dat",
"opencode.workspace.old.dat",
"opencode.workspace.recent.dat",
])
}),
),
)
expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"])
expect(remaining).toHaveLength(100)
})
test("caps scoped stores by recency", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
const now = new Date("2026-07-01T00:00:00.000Z")
yield* Effect.forEach(
Array.from({ length: 102 }, (_, index) => index),
(index) =>
writeStore(
root,
`opencode.draft.${index}.dat`,
'{"draft:prompt":"hello"}',
new Date(now.getTime() - index * 1000),
),
{ concurrency: "unbounded", discard: true },
)
test("removes a scoped store immediately when it becomes empty", async () => {
const root = await tempRoot()
await writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
await writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
const result = yield* cleanupStoreFiles(root, now.getTime())
const remaining = yield* fs.readDirectory(root)
expect(await deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true)
expect(await deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false)
expect(await readdir(root)).toEqual(["opencode.global.dat"])
})
expect(result.deleted.sort()).toEqual(["opencode.draft.100.dat", "opencode.draft.101.dat"])
expect(remaining).toHaveLength(100)
}),
),
)
test("removes a scoped store immediately when it becomes empty", () =>
run(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const root = yield* tempRoot()
yield* writeStore(root, "opencode.draft.empty.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
yield* writeStore(root, "opencode.global.dat", "{}", new Date("2026-07-01T00:00:00.000Z"))
expect(yield* deleteStoreFileIfEmpty(root, "opencode.draft.empty.dat")).toBe(true)
expect(yield* deleteStoreFileIfEmpty(root, "opencode.global.dat")).toBe(false)
expect(yield* fs.readDirectory(root)).toEqual(["opencode.global.dat"])
}),
),
)
})
+49 -39
View File
@@ -1,5 +1,4 @@
import { readdir, readFile, rm, stat } from "node:fs/promises"
import { join } from "node:path"
import { Effect, FileSystem, Option, Path } from "effect"
const EMPTY_STORE_MAX_BYTES = 128
const DRAFT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
@@ -14,30 +13,33 @@ type StoreCandidate = {
empty: boolean
}
export async function cleanupStoreFiles(userDataPath: string, now = Date.now()) {
const entries = await readdir(userDataPath, { withFileTypes: true }).catch(() => [])
const candidates = (
await Promise.all(
entries
.filter((entry) => entry.isFile())
.map(async (entry) => {
const kind = storeKind(entry.name)
if (!kind) return
export const cleanupStoreFiles = Effect.fn("Storage.cleanupStoreFiles")(function* (
userDataPath: string,
now = Date.now(),
) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const entries = yield* fs.readDirectory(userDataPath).pipe(Effect.catch(() => Effect.succeed([])))
const candidates = (yield* Effect.forEach(
entries,
Effect.fnUntraced(function* (entry) {
const kind = storeKind(entry)
if (!kind) return
const file = join(userDataPath, entry.name)
const stats = await stat(file).catch(() => undefined)
if (!stats?.isFile()) return
const file = path.join(userDataPath, entry)
const stats = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (stats?.type !== "File") return
return {
name: entry.name,
path: file,
kind,
modified: stats.mtimeMs,
empty: await isEmptyStore(file, stats.size),
}
}),
)
).filter((candidate) => !!candidate)
return {
name: entry,
path: file,
kind,
modified: Option.getOrElse(stats.mtime, () => new Date(0)).getTime(),
empty: yield* isEmptyStore(file, stats.size),
}
}),
{ concurrency: 5 },
)).filter((candidate) => !!candidate)
const stale = new Set<StoreCandidate>()
for (const candidate of candidates) {
@@ -51,37 +53,45 @@ export async function cleanupStoreFiles(userDataPath: string, now = Date.now())
.slice(DRAFT_KEEP_RECENT)
.forEach((candidate) => stale.add(candidate))
const deleted = await Promise.all(
[...stale].map(async (candidate) => {
await rm(candidate.path, { force: true })
const deleted = yield* Effect.forEach(
stale,
Effect.fnUntraced(function* (candidate) {
yield* fs.remove(candidate.path, { force: true })
return candidate.name
}),
{ concurrency: "unbounded" },
)
return { scanned: candidates.length, deleted }
}
})
export async function deleteStoreFileIfEmpty(userDataPath: string, name: string) {
export const deleteStoreFileIfEmpty = Effect.fn("Storage.deleteStoreFileIfEmpty")(function* (
userDataPath: string,
name: string,
) {
if (!storeKind(name)) return false
const file = join(userDataPath, name)
const stats = await stat(file).catch(() => undefined)
if (!stats?.isFile()) return false
if (!(await isEmptyStore(file, stats.size))) return false
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const file = path.join(userDataPath, name)
const stats = yield* fs.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (stats?.type !== "File") return false
if (!(yield* isEmptyStore(file, stats.size))) return false
await rm(file, { force: true })
yield* fs.remove(file, { force: true })
return true
}
})
function storeKind(name: string): StoreKind | undefined {
if (/^opencode\.draft\..+\.dat$/.test(name)) return "draft"
if (/^opencode\.workspace\..+\.dat$/.test(name)) return "workspace"
}
async function isEmptyStore(file: string, size: number) {
if (size > EMPTY_STORE_MAX_BYTES) return false
const isEmptyStore = Effect.fn("Storage.isEmptyStore")(function* (file: string, size: FileSystem.Size) {
if (size > FileSystem.Size(EMPTY_STORE_MAX_BYTES)) return false
const raw = await readFile(file, "utf8").catch(() => undefined)
const fs = yield* FileSystem.FileSystem
const raw = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (raw === undefined) return false
if (raw.trim() === "") return true
@@ -91,4 +101,4 @@ async function isEmptyStore(file: string, size: number) {
} catch {
return false
}
}
})
+44 -15
View File
@@ -1,13 +1,46 @@
import { join } from "node:path"
import { app } from "electron"
export * as DesktopStorage from "./index"
import { app, BrowserWindow } from "electron"
import { Context, Effect, Layer, Path } from "effect"
import { createDesktopDraftStore } from "./drafts"
import { getStore, removeStoreFileIfEmpty } from "./store"
export function createDesktopStorage() {
const drafts = createDesktopDraftStore(join(app.getPath("userData"), "drafts.sqlite"))
app.on("before-quit", () => drafts.flush())
app.once("will-quit", () => drafts.close())
app.on("browser-window-created", (_event, win) => win.on("session-end", () => drafts.flush()))
export type Interface = ReturnType<typeof make>
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/DesktopStorage") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const path = yield* Path.Path
const storage = make(path.join(app.getPath("userData"), "drafts.sqlite"))
const flush = () => storage.drafts.flush()
const wire = (_event: Electron.Event, win: BrowserWindow) => win.on("session-end", flush)
app.on("before-quit", flush)
app.on("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => wire({} as Electron.Event, win))
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
app.off("before-quit", flush)
app.off("browser-window-created", wire)
BrowserWindow.getAllWindows().forEach((win) => win.off("session-end", flush))
storage.drafts.close()
}),
)
return Service.of(storage)
}),
)
function make(draftFile: string) {
const drafts = createDesktopDraftStore(draftFile)
const deleteValue = Effect.fn("DesktopStorage.delete")(function* (name: string, key: string) {
getStore(name).delete(key)
yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore)
})
const clear = Effect.fn("DesktopStorage.clear")(function* (name: string) {
getStore(name).clear()
yield* removeStoreFileIfEmpty(name).pipe(Effect.ignore)
})
return {
get(name: string, key: string) {
@@ -20,14 +53,8 @@ export function createDesktopStorage() {
}
},
set: (name: string, key: string, value: string) => getStore(name).set(key, value),
deleteValue(name: string, key: string) {
getStore(name).delete(key)
void removeStoreFileIfEmpty(name)
},
clear(name: string) {
getStore(name).clear()
void removeStoreFileIfEmpty(name)
},
deleteValue,
clear,
keys: (name: string) => Object.keys(getStore(name).store),
length: (name: string) => Object.keys(getStore(name).store).length,
drafts: {
@@ -38,6 +65,8 @@ export function createDesktopStorage() {
const data = drafts.getBlob(id)
return data ? new Uint8Array(data).buffer : null
},
flush: drafts.flush,
close: drafts.close,
},
}
}
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test"
import { hasExistingAppState } from "./install-state"
const file = (name: string) => ({ name, isDirectory: () => false })
const directory = (name: string) => ({ name, isDirectory: () => true })
const file = (name: string) => ({ name, directory: false })
const directory = (name: string) => ({ name, directory: true })
describe("hasExistingAppState", () => {
test("ignores files Electron may create on a fresh install", () => {
@@ -1,8 +1,8 @@
export function hasExistingAppState(entries: Array<{ name: string; isDirectory: () => boolean }>) {
export function hasExistingAppState(entries: Array<{ name: string; directory: boolean }>) {
return entries.some((entry) => {
if (entry.name === "opencode.settings") return true
if (entry.name.endsWith(".dat")) return true
if (/^window-state-.+\.json$/.test(entry.name)) return true
return entry.isDirectory() && entry.name === "opencode"
return entry.directory && entry.name === "opencode"
})
}
+5 -7
View File
@@ -1,7 +1,6 @@
import Store from "electron-store"
import electron from "electron"
import { rmSync } from "node:fs"
import { join } from "node:path"
import { Effect } from "effect"
import { deleteStoreFileIfEmpty } from "./cleanup"
import { SETTINGS_STORE } from "./keys"
@@ -25,11 +24,10 @@ export function getStore(name = SETTINGS_STORE) {
return next
}
export async function removeStoreFileIfEmpty(name: string) {
if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
}
export const removeStoreFileIfEmpty = Effect.fn("DesktopStorage.removeStoreFileIfEmpty")(function* (name: string) {
if (yield* deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name)
})
export function removeStoreFile(name: string) {
rmSync(join(electron.app.getPath("userData"), name), { force: true })
export function forgetStore(name: string) {
cache.delete(name)
}
@@ -1,131 +0,0 @@
import type { UpdaterState } from "@opencode-ai/app/updater"
export type { UpdaterState } from "@opencode-ai/app/updater"
export type UpdaterReadyRecord = { version: string }
export type UpdaterPlatform = {
checkForUpdate(): Promise<string | undefined>
stageUpdate(): Promise<unknown>
installAndRestart(): Promise<never>
}
export type UpdaterLifecycle = {
prepareToRestart(): Promise<void>
}
type UpdaterPersistence = {
get(): UpdaterReadyRecord | undefined | Promise<UpdaterReadyRecord | undefined>
set(value: UpdaterReadyRecord): void | Promise<void>
clear(): void | Promise<void>
}
export function createUpdaterController(input: {
currentVersion: string
platform?: UpdaterPlatform
lifecycle: UpdaterLifecycle
persistence: UpdaterPersistence
log?: (message: string, data?: object) => void
}) {
let state: UpdaterState = input.platform ? { status: "idle" } : { status: "disabled" }
let pending: Promise<UpdaterState> | undefined
let installing: Promise<void> | undefined
const listeners = new Set<(state: UpdaterState) => void>()
const transition = (next: UpdaterState) => {
input.log?.("updater state changed", { from: state.status, to: next.status })
state = next
listeners.forEach((listener) => listener(state))
return state
}
const check = () => {
const platform = input.platform
if (!platform) return Promise.resolve(state)
if (state.status === "installing") return Promise.resolve(state)
if (pending) return pending
pending = (state.status === "ready" ? refreshStaged(platform, state.version) : findAndStage(platform)).finally(
() => {
pending = undefined
},
)
return pending
}
const findAndStage = (platform: UpdaterPlatform) =>
(async () => {
transition({ status: "checking" })
const version = await platform.checkForUpdate()
if (!version || version === input.currentVersion) {
await input.persistence.clear()
return transition({ status: "up-to-date" })
}
transition({ status: "downloading", version })
await platform.stageUpdate()
await input.persistence.set({ version })
return transition({ status: "ready", version })
})().catch((error) =>
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
)
// A staged update stays visible and installable throughout: the refresh makes no
// transitions until a newer version is staged, and a failure keeps the current one.
const refreshStaged = (platform: UpdaterPlatform, staged: string) =>
(async () => {
const version = await platform.checkForUpdate()
if (!version || version === staged || version === input.currentVersion) return state
await platform.stageUpdate()
await input.persistence.set({ version })
// An install may have started while this stage was in flight; keep its status
// and show the newer version instead of flickering back to ready.
return transition({ status: installing ? "installing" : "ready", version })
})().catch((error) => {
input.log?.("updater refresh failed, keeping staged update", {
staged,
message: error instanceof Error ? error.message : String(error),
})
return state
})
const install = () => {
if (installing) return installing
const platform = input.platform
if (!platform || state.status !== "ready") return Promise.reject(new Error("Update is not ready to install"))
const staged = state.version
transition({ status: "installing", version: staged })
installing = (async () => {
// Installation is the commit point: refresh once more so one restart lands
// on the newest release, or keep the known-good staged update if checking fails.
await (pending ?? refreshStaged(platform, staged))
await input.lifecycle.prepareToRestart()
await platform.installAndRestart()
})().catch((error) => {
installing = undefined
if (state.status === "installing") transition({ status: "ready", version: state.version })
throw error
})
return installing
}
return {
getState: () => state,
subscribe(listener: (state: UpdaterState) => void) {
listeners.add(listener)
listener(state)
return () => listeners.delete(listener)
},
async start() {
const ready = await input.persistence.get()
if (ready?.version === input.currentVersion) await input.persistence.clear()
return check()
},
check,
install,
}
}
export type UpdaterController = ReturnType<typeof createUpdaterController>
@@ -1,71 +1,91 @@
import { describe, expect, test } from "bun:test"
import { createUpdaterController, type UpdaterReadyRecord } from "./controller"
import { afterEach, describe, expect, test } from "bun:test"
import { Effect, ManagedRuntime } from "effect"
import { type Dependencies, layerWith, Service } from "./index"
// Drives the controller the way the app does: start or check, observe the states
// the renderer sees, then install like a button click. `calls` records the platform
const dispose: Array<() => Promise<void>> = []
afterEach(async () => {
await Promise.all(dispose.splice(0).map((run) => run()))
})
// Drives the updater the way the app does: start or check, then install like a button click. `calls` records the platform
// operations in order; installs record the staged version they would apply.
function setup(input?: {
currentVersion?: string
ready?: UpdaterReadyRecord
ready?: { version: string }
latest?: () => string
stage?: () => Promise<void>
install?: () => Promise<never>
}) {
const calls: string[] = []
const states: string[] = []
let ready = input?.ready
const controller = createUpdaterController({
const dependencies: Dependencies = {
currentVersion: input?.currentVersion ?? "1.0.0",
platform: {
async checkForUpdate() {
calls.push("check")
return input?.latest?.() ?? "2.0.0"
},
async stageUpdate() {
checkForUpdate: Effect.try({
try: () => {
calls.push("check")
return input?.latest?.() ?? "2.0.0"
},
catch: (error) => error,
}),
stageUpdate: Effect.tryPromise(async () => {
calls.push("download")
await input?.stage?.()
},
installAndRestart() {
}),
installAndRestart: Effect.suspend(() => {
calls.push(`install:${ready?.version}`)
return input?.install?.() ?? new Promise<never>(() => {})
},
},
lifecycle: {
async prepareToRestart() {
calls.push("prepare")
},
return Effect.tryPromise({
try: () => input?.install?.() ?? new Promise<never>(() => {}),
catch: (error) => error,
})
}),
dispose: () => {},
},
prepareToRestart: Effect.sync(() => {
calls.push("prepare")
}),
persistence: {
get: () => ready,
set: (value) => {
ready = value
},
clear: () => {
get: Effect.sync(() => ready),
set: (value) =>
Effect.sync(() => {
ready = value
}),
clear: Effect.sync(() => {
ready = undefined
},
}),
},
})
controller.subscribe((state) => states.push(state.status))
return { controller, calls, states, getReady: () => ready }
}
const runtime = ManagedRuntime.make(layerWith(dependencies))
dispose.push(() => runtime.dispose())
const run = <A, E>(effect: (updater: Service) => Effect.Effect<A, E>) =>
runtime.runPromise(Service.pipe(Effect.flatMap(effect)))
const updater = {
start: () => run((updater) => updater.started),
check: () => run((updater) => updater.check),
install: () => run((updater) => updater.install),
installFork: () => runtime.runFork(Service.pipe(Effect.flatMap((updater) => updater.install))),
getState: () => run((updater) => updater.state),
}
return { updater, calls, getReady: () => ready }
}
describe("updater controller", () => {
describe("updater", () => {
test("stages an update found at launch and shows it as ready", async () => {
const app = setup()
await app.controller.start()
await app.updater.start()
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(app.getReady()).toEqual({ version: "2.0.0" })
})
test("reports up to date and clears the record once the update is installed", async () => {
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" } })
await app.controller.start()
await app.updater.start()
expect(app.states).toEqual(["idle", "checking", "up-to-date"])
expect(await app.updater.getState()).toEqual({ status: "up-to-date" })
expect(app.calls).toEqual(["check"])
expect(app.getReady()).toBeUndefined()
})
@@ -73,38 +93,38 @@ describe("updater controller", () => {
test("revalidates a persisted target through the updater cache on launch", async () => {
const app = setup({ ready: { version: "2.0.0" } })
await app.controller.start()
await app.updater.start()
expect(app.calls).toEqual(["check", "download"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("concurrent checks share one platform check", async () => {
const app = setup()
await Promise.all([app.controller.check(), app.controller.check(), app.controller.check()])
await Promise.all([app.updater.check(), app.updater.check(), app.updater.check()])
expect(app.calls).toEqual(["check", "download"])
})
test("clicking install twice checks once and installs the staged version once", async () => {
const app = setup()
await app.controller.start()
await app.updater.start()
void app.controller.install()
void app.controller.install()
app.updater.installFork()
app.updater.installFork()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
expect(await app.updater.getState()).toEqual({ status: "installing", version: "2.0.0" })
})
test("ignores checks while an installation is in progress", async () => {
const app = setup()
await app.controller.start()
void app.controller.install()
await app.updater.start()
app.updater.installFork()
await app.controller.check()
await app.updater.check()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
@@ -113,15 +133,15 @@ describe("updater controller", () => {
test("clicking install downloads and installs a newer release", async () => {
let latest = "2.0.0"
const app = setup({ latest: () => latest })
await app.controller.start()
await app.updater.start()
latest = "3.0.0"
void app.controller.install()
app.updater.installFork()
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
expect(await app.updater.getState()).toEqual({ status: "installing", version: "2.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "3.0.0" })
expect(await app.updater.getState()).toEqual({ status: "installing", version: "3.0.0" })
})
test("clicking install uses the staged release when the final check fails", async () => {
@@ -132,29 +152,27 @@ describe("updater controller", () => {
return "2.0.0"
},
})
await app.controller.start()
await app.updater.start()
offline = true
void app.controller.install()
app.updater.installFork()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "prepare", "install:2.0.0"])
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
expect(await app.updater.getState()).toEqual({ status: "installing", version: "2.0.0" })
})
test("later checks stay silent while ready and pick up newer versions", async () => {
let latest = "2.0.0"
const app = setup({ latest: () => latest })
await app.controller.start()
await app.updater.start()
await app.controller.check()
await app.updater.check()
// Nothing new was published: the install button never hid.
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
latest = "3.0.0"
await app.controller.check()
expect(app.states).toEqual(["idle", "checking", "downloading", "ready", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "3.0.0" })
await app.updater.check()
expect(await app.updater.getState()).toEqual({ status: "ready", version: "3.0.0" })
expect(app.getReady()).toEqual({ version: "3.0.0" })
})
@@ -166,13 +184,12 @@ describe("updater controller", () => {
return "2.0.0"
},
})
await app.controller.start()
await app.updater.start()
offline = true
await app.controller.check()
await app.updater.check()
expect(app.states).toEqual(["idle", "checking", "downloading", "ready"])
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(app.getReady()).toEqual({ version: "2.0.0" })
})
@@ -189,19 +206,19 @@ describe("updater controller", () => {
})
},
})
await app.controller.start()
await app.updater.start()
latest = "3.0.0"
slowStage = true
const refresh = app.controller.check()
const refresh = app.updater.check()
await new Promise((resolve) => setTimeout(resolve, 0))
void app.controller.install()
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
app.updater.installFork()
expect(await app.updater.getState()).toEqual({ status: "installing", version: "2.0.0" })
releaseStage()
await refresh
expect(app.controller.getState()).toEqual({ status: "installing", version: "3.0.0" })
expect(await app.updater.getState()).toEqual({ status: "installing", version: "3.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
})
@@ -215,14 +232,14 @@ describe("updater controller", () => {
return new Promise<never>(() => {})
},
})
await app.controller.start()
await app.updater.start()
await expect(app.controller.install()).rejects.toThrow("install failed")
expect(app.controller.getState()).toEqual({ status: "ready", version: "2.0.0" })
await expect(app.updater.install()).rejects.toThrow("install failed")
expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" })
void app.controller.install()
app.updater.installFork()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(attempts).toBe(2)
expect(app.controller.getState()).toEqual({ status: "installing", version: "2.0.0" })
expect(await app.updater.getState()).toEqual({ status: "installing", version: "2.0.0" })
})
})
+172 -86
View File
@@ -1,99 +1,185 @@
import { app, dialog } from "electron"
export * as Updater from "./index"
import type { WebContents } from "electron"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { UPDATER_ENABLED } from "../constants"
import { getLogger } from "../native/logging"
import { nativeT } from "../native/translations"
import { getStore } from "../storage/store"
import { createUpdaterController, type UpdaterController, type UpdaterReadyRecord } from "./controller"
import { Context, Deferred, Effect, Exit, Fiber, Layer } from "effect"
import type { UpdaterState } from "@opencode-ai/app/updater"
import { UpdaterStateChanged } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
const key = "ready"
export async function setupAutoUpdater(prepareToRestart: () => Promise<void>) {
const logger = getLogger()
const store = getStore("opencode.updater")
const platform = UPDATER_ENABLED ? (await import("./platform")).createUpdaterPlatform(logger) : undefined
return createUpdaterController({
currentVersion: app.getVersion(),
platform,
lifecycle: { prepareToRestart },
persistence: {
get() {
const value = store.get(key)
if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string")
return undefined
return { version: value.version } satisfies UpdaterReadyRecord
},
set: (value) => store.set(key, value),
clear: () => store.delete(key),
},
log: (message, data) => logger.log(message, data),
})
export type Platform = {
readonly checkForUpdate: Effect.Effect<string | undefined, unknown>
readonly stageUpdate: Effect.Effect<unknown, unknown>
readonly installAndRestart: Effect.Effect<never, unknown>
readonly dispose: () => void
}
export function startAutoUpdater(controller: UpdaterController) {
void controller.start()
const timer = setInterval(() => void controller.check(), 10 * 60 * 1000)
timer.unref()
app.once("will-quit", () => clearInterval(timer))
export type Dependencies = {
readonly currentVersion: string
readonly platform?: Platform
readonly prepareToRestart: Effect.Effect<void, unknown>
readonly persistence: {
readonly get: Effect.Effect<{ version: string } | undefined, unknown>
readonly set: (value: { version: string }) => Effect.Effect<void, unknown>
readonly clear: Effect.Effect<void, unknown>
}
readonly show?: (
check: Effect.Effect<UpdaterState>,
install: Effect.Effect<void, unknown>,
) => Effect.Effect<void, unknown>
}
export function createUpdaterIpc(controller: UpdaterController) {
export interface Interface {
readonly subscribe: (sender: WebContents) => Effect.Effect<void>
readonly unsubscribe: (id: number) => Effect.Effect<void>
readonly check: Effect.Effect<UpdaterState>
readonly install: Effect.Effect<void>
readonly show: Effect.Effect<void>
readonly state: Effect.Effect<UpdaterState>
readonly started: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/Updater") {}
export const layerWith = (dependencies: Dependencies) => Layer.effect(Service, make(dependencies))
export const make = Effect.fn("Updater.make")(function* (dependencies: Dependencies) {
const runFork = Effect.runForkWith(yield* Effect.context())
let state: UpdaterState = dependencies.platform ? { status: "idle" } : { status: "disabled" }
let pending: Deferred.Deferred<UpdaterState> | undefined
let installing: Deferred.Deferred<void, unknown> | undefined
const listeners = new Set<(state: UpdaterState) => void>()
const subscriptions = new Map<number, () => void>()
const transition = (next: UpdaterState) => {
runFork(Effect.logInfo("updater state changed", { from: state.status, to: next.status }))
state = next
listeners.forEach((listener) => listener(state))
return state
}
const findAndStage = (platform: Platform) =>
Effect.gen(function* () {
yield* Effect.sync(() => transition({ status: "checking" }))
const version = yield* platform.checkForUpdate
if (!version || version === dependencies.currentVersion) {
yield* dependencies.persistence.clear
return transition({ status: "up-to-date" })
}
transition({ status: "downloading", version })
yield* platform.stageUpdate
yield* dependencies.persistence.set({ version })
return transition({ status: "ready", version })
}).pipe(
Effect.catch((error) =>
Effect.sync(() =>
transition({ status: "error", message: error instanceof Error ? error.message : String(error) }),
),
),
)
const refreshStaged = (platform: Platform, staged: string) =>
Effect.gen(function* () {
const version = yield* platform.checkForUpdate
if (!version || version === staged || version === dependencies.currentVersion) return state
yield* platform.stageUpdate
yield* dependencies.persistence.set({ version })
return transition({ status: installing ? "installing" : "ready", version })
}).pipe(
Effect.catch((error) =>
Effect.sync(() => {
runFork(
Effect.logWarning("updater refresh failed, keeping staged update", {
staged,
message: error instanceof Error ? error.message : String(error),
}),
)
return state
}),
),
)
const check = Effect.suspend(() => {
const platform = dependencies.platform
if (!platform || state.status === "installing") return Effect.succeed(state)
if (pending) return Deferred.await(pending)
const deferred = Deferred.makeUnsafe<UpdaterState>()
pending = deferred
return (state.status === "ready" ? refreshStaged(platform, state.version) : findAndStage(platform)).pipe(
Effect.tap((result) => Deferred.succeed(deferred, result)),
Effect.ensuring(Effect.sync(() => (pending = undefined))),
)
})
const install = Effect.suspend(() => {
if (installing) return Deferred.await(installing)
const platform = dependencies.platform
if (!platform || state.status !== "ready") return Effect.fail(new Error("Update is not ready to install"))
const staged = state.version
transition({ status: "installing", version: staged })
const deferred = Deferred.makeUnsafe<void, unknown>()
installing = deferred
return Effect.gen(function* () {
yield* pending ? Deferred.await(pending) : refreshStaged(platform, staged)
yield* dependencies.prepareToRestart
return yield* platform.installAndRestart
}).pipe(
Effect.exit,
Effect.flatMap((exit) =>
Deferred.done(deferred, exit).pipe(
Effect.andThen(
Effect.sync(() => {
installing = undefined
if (Exit.isFailure(exit) && state.status === "installing") {
transition({ status: "ready", version: state.version })
}
}),
),
Effect.andThen(Deferred.await(deferred)),
),
),
)
})
const start = Effect.gen(function* () {
const ready = yield* dependencies.persistence.get
if (ready?.version === dependencies.currentVersion) yield* dependencies.persistence.clear
yield* check
})
const unsubscribe = (id: number) => {
subscriptions.get(id)?.()
subscriptions.delete(id)
}
app.once("will-quit", () => subscriptions.forEach((dispose) => dispose()))
const starting = yield* start.pipe(Effect.forkScoped)
yield* Effect.gen(function* () {
yield* Effect.sleep("10 minutes")
yield* check
}).pipe(Effect.forever, Effect.forkScoped)
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
dependencies.platform?.dispose()
subscriptions.forEach((dispose) => dispose())
subscriptions.clear()
}),
)
return {
subscribe(sender: WebContents) {
const id = sender.id
subscriptions.get(id)?.() // a reloaded renderer replaces its previous subscription
subscriptions.set(
id,
controller.subscribe((state) => {
if (sender.isDestroyed()) return unsubscribe(id)
sendIpcEvent(sender, Ipc.updater.state, state)
}),
)
sender.once("destroyed", () => unsubscribe(id))
},
unsubscribe,
check: () => controller.check(),
install: () => controller.install(),
}
}
export type UpdaterIpc = ReturnType<typeof createUpdaterIpc>
export async function showUpdaterDialog(controller: UpdaterController) {
const state = await controller.check()
if (state.status === "error") {
await dialog.showMessageBox({
type: "error",
message: nativeT("desktop.updater.dialog.checkFailed.message"),
title: nativeT("desktop.updater.dialog.checkFailed.title"),
})
return
}
if (state.status === "up-to-date") {
await dialog.showMessageBox({
type: "info",
message: nativeT("desktop.updater.dialog.upToDate.message"),
title: nativeT("desktop.updater.dialog.upToDate.title"),
})
return
}
if (state.status !== "ready") return
const response = await dialog.showMessageBox({
type: "info",
message: nativeT("desktop.updater.dialog.ready.message", { version: state.version }),
title: nativeT("desktop.updater.dialog.ready.title"),
buttons: [nativeT("desktop.updater.dialog.restart"), nativeT("desktop.updater.dialog.later")],
defaultId: 0,
cancelId: 1,
return Service.of({
subscribe: (sender) =>
Effect.sync(() => {
const id = sender.id
subscriptions.get(id)?.()
subscriptions.set(
id,
(() => {
const listener = (state: UpdaterState) => {
if (sender.isDestroyed()) return unsubscribe(id)
emitIpcEvent(sender, new UpdaterStateChanged({ state }))
}
listeners.add(listener)
listener(state)
return () => listeners.delete(listener)
})(),
)
sender.once("destroyed", () => unsubscribe(id))
}),
unsubscribe: (id) => Effect.sync(() => unsubscribe(id)),
check,
install: install.pipe(Effect.orDie),
show: dependencies.show ? dependencies.show(check, install).pipe(Effect.orDie) : Effect.void,
state: Effect.sync(() => state),
started: Fiber.join(starting).pipe(Effect.orDie),
})
if (response.response === 0) await controller.install()
}
})
+85
View File
@@ -0,0 +1,85 @@
export * as UpdaterLive from "./live"
import { dialog } from "electron"
import { Effect, Layer } from "effect"
import type { UpdaterState } from "@opencode-ai/app/updater"
import { UPDATER_ENABLED } from "../constants"
import { DesktopInitialization } from "../lifecycle/desktop-initialization"
import { ApplicationLifecycle } from "../lifecycle"
import { nativeT } from "../native/translations"
import { make, Service } from "./index"
const key = "ready"
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
const desktop = yield* DesktopInitialization.Service
const platform = UPDATER_ENABLED
? yield* Effect.gen(function* () {
const { make } = yield* Effect.promise(() => import("./platform"))
return yield* make
})
: undefined
return yield* make({
currentVersion: desktop.version,
platform,
prepareToRestart: lifecycle.prepareToRestart,
persistence: {
get: Effect.sync(() => {
const value = desktop.updaterStore.get(key)
if (!value || typeof value !== "object" || !("version" in value) || typeof value.version !== "string") return
return { version: value.version }
}),
set: (value) => Effect.sync(() => desktop.updaterStore.set(key, value)),
clear: Effect.sync(() => desktop.updaterStore.delete(key)),
},
show,
})
}),
)
const show = Effect.fn("Updater.show")(function* (
check: Effect.Effect<UpdaterState>,
install: Effect.Effect<void, unknown>,
) {
const state = yield* check
if (state.status === "error") {
yield* promise(() =>
dialog.showMessageBox({
type: "error",
message: nativeT("desktop.updater.dialog.checkFailed.message"),
title: nativeT("desktop.updater.dialog.checkFailed.title"),
}),
)
return
}
if (state.status === "up-to-date") {
yield* promise(() =>
dialog.showMessageBox({
type: "info",
message: nativeT("desktop.updater.dialog.upToDate.message"),
title: nativeT("desktop.updater.dialog.upToDate.title"),
}),
)
return
}
if (state.status !== "ready") return
const response = yield* promise(() =>
dialog.showMessageBox({
type: "info",
message: nativeT("desktop.updater.dialog.ready.message", { version: state.version }),
title: nativeT("desktop.updater.dialog.ready.title"),
buttons: [nativeT("desktop.updater.dialog.restart"), nativeT("desktop.updater.dialog.later")],
defaultId: 0,
cancelId: 1,
}),
)
if (response.response === 0) yield* install
})
function promise<A>(evaluate: () => Promise<A>) {
return Effect.tryPromise(evaluate).pipe(Effect.orDie)
}
+67 -50
View File
@@ -1,89 +1,106 @@
import { app, autoUpdater } from "electron"
import pkg from "electron-updater"
import { getLogger } from "../native/logging"
import { Effect } from "effect"
import { setAppQuitting } from "../windows"
import type { UpdaterPlatform } from "./controller"
import type { Platform } from "./index"
const updateClient = pkg.autoUpdater
const restartTimeout = 10_000
export function createUpdaterPlatform(logger: ReturnType<typeof getLogger>): UpdaterPlatform {
configureUpdater(logger)
autoUpdater.on("before-quit-for-update", () => setAppQuitting())
return {
async checkForUpdate() {
const result = await updateClient.checkForUpdates()
if (!result?.isUpdateAvailable) return
return result.updateInfo.version
},
stageUpdate,
installAndRestart: () => installAndRestart(logger),
export const make = Effect.gen(function* () {
const runFork = Effect.runForkWith(yield* Effect.context())
updateClient.logger = {
info: (...args) => runFork(Effect.logInfo(...args)),
warn: (...args) => runFork(Effect.logWarning(...args)),
error: (...args) => runFork(Effect.logError(...args)),
debug: (...args) => runFork(Effect.logDebug(...args)),
}
}
function configureUpdater(logger: ReturnType<typeof getLogger>) {
updateClient.logger = logger
updateClient.channel = "latest"
updateClient.allowPrerelease = false
updateClient.allowDowngrade = true
updateClient.autoDownload = false
updateClient.autoInstallOnAppQuit = process.platform === "darwin"
logger.log("auto updater configured", {
yield* Effect.logInfo("auto updater configured", {
channel: updateClient.channel,
allowPrerelease: updateClient.allowPrerelease,
allowDowngrade: updateClient.allowDowngrade,
currentVersion: app.getVersion(),
})
}
const beforeQuit = () => setAppQuitting()
autoUpdater.on("before-quit-for-update", beforeQuit)
return {
checkForUpdate: Effect.tryPromise({
try: async () => {
const result = await updateClient.checkForUpdates()
return result?.isUpdateAvailable ? result.updateInfo.version : undefined
},
catch: (error) => error,
}),
stageUpdate: stageUpdate(),
installAndRestart,
dispose: () => autoUpdater.off("before-quit-for-update", beforeQuit),
} satisfies Platform
})
function stageUpdate() {
if (process.platform !== "darwin") return updateClient.downloadUpdate()
if (process.platform !== "darwin")
return Effect.tryPromise({
try: () => updateClient.downloadUpdate(),
catch: (error) => error,
}).pipe(Effect.asVoid)
return new Promise<void>((resolve, reject) => {
return Effect.callback<void, Error>((resume) => {
const cleanup = () => {
autoUpdater.removeListener("update-downloaded", complete)
updateClient.removeListener("error", fail)
}
const complete = () => {
cleanup()
resolve()
resume(Effect.void)
}
const fail = (error: Error) => {
cleanup()
reject(error)
resume(Effect.fail(error))
}
autoUpdater.once("update-downloaded", complete)
updateClient.once("error", fail)
void updateClient.downloadUpdate().catch(fail)
return Effect.sync(cleanup)
})
}
function installAndRestart(logger: ReturnType<typeof getLogger>) {
return new Promise<never>((_resolve, reject) => {
const timeout = setTimeout(() => {
logger.error("update restart did not start")
fail(new Error())
}, restartTimeout)
const started = () => {
clearTimeout(timeout)
autoUpdater.removeListener("before-quit-for-update", started)
}
const fail = (error: Error) => {
clearTimeout(timeout)
autoUpdater.removeListener("before-quit-for-update", started)
updateClient.removeListener("error", fail)
setAppQuitting(false)
reject(error)
}
const installAndRestart = Effect.callback<void, Error>((resume) => {
const cleanup = () => {
autoUpdater.removeListener("before-quit-for-update", started)
updateClient.removeListener("error", fail)
}
const started = () => {
cleanup()
resume(Effect.void)
}
const fail = (error: Error) => {
cleanup()
resume(Effect.fail(error))
}
autoUpdater.once("before-quit-for-update", started)
updateClient.once("error", fail)
try {
updateClient.quitAndInstall()
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)))
}
})
}
autoUpdater.once("before-quit-for-update", started)
updateClient.once("error", fail)
try {
updateClient.quitAndInstall()
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)))
}
return Effect.sync(cleanup)
}).pipe(
Effect.timeoutOrElse({
duration: restartTimeout,
orElse: () =>
Effect.logError("update restart did not start").pipe(
Effect.andThen(Effect.fail(new Error("Update restart did not start"))),
),
}),
Effect.tapError(() => Effect.sync(() => setAppQuitting(false))),
Effect.andThen(Effect.never),
)
+19 -15
View File
@@ -2,9 +2,11 @@ import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { DesktopTheme } from "@opencode-ai/ui/theme/types"
import oc2ThemeJson from "../../../../ui/src/theme/themes/oc-2.json"
import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
import { join } from "node:path"
import { Ipc, sendIpcEvent, type TitlebarTheme } from "../../shared/ipc-contract"
import { developmentResourcesRoot, preloadPath } from "../paths"
import type { Path } from "effect"
import { type TitlebarTheme } from "../../shared/ipc-contract"
import { WindowFullscreenChanged, WindowPinchZoomChanged, WindowZoomChanged } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import type { DesktopPaths } from "../paths"
import { BACKGROUND_COLOR_KEY, PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
@@ -20,12 +22,12 @@ const maxZoomLevel = 10
const minZoomLevel = 0.2
let backgroundColor: string | undefined
export function windowAppearance() {
export function windowAppearance(path: Path.Path, paths: DesktopPaths.Resolved) {
const mode = tone()
const storedBackground = getStore().get(BACKGROUND_COLOR_KEY)
return {
title: "OpenCode",
icon: iconPath(),
icon: iconPath(path, paths),
backgroundColor:
backgroundColor ?? (typeof storedBackground === "string" ? storedBackground : undefined) ?? oc2Background[mode],
...(process.platform === "darwin"
@@ -42,7 +44,7 @@ export function windowAppearance() {
}
: {}),
webPreferences: {
preload: preloadPath,
preload: paths.preloadPath,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
@@ -50,9 +52,9 @@ export function windowAppearance() {
}
}
export function setDockIcon() {
export function setDockIcon(path: Path.Path, paths: DesktopPaths.Resolved) {
if (process.platform !== "darwin") return
const icon = nativeImage.createFromPath(join(iconsDir(), "dock.png"))
const icon = nativeImage.createFromPath(path.join(iconsDir(path, paths), "dock.png"))
if (!icon.isEmpty()) app.dock?.setIcon(icon)
}
@@ -86,7 +88,7 @@ export function setPinchZoomEnabled(enabled: boolean) {
getStore().set(PINCH_ZOOM_ENABLED_KEY, enabled)
BrowserWindow.getAllWindows().forEach((win) => {
pinchZoomEnabled.set(win, enabled)
sendIpcEvent(win.webContents, Ipc.window.pinchZoomEnabledChanged, enabled)
emitIpcEvent(win.webContents, new WindowPinchZoomChanged({ enabled }))
if (!enabled && win.webContents.getZoomFactor() !== 1) win.webContents.setZoomFactor(1)
updateZoom(win)
})
@@ -115,18 +117,20 @@ export function wireZoom(win: BrowserWindow) {
export function wireFullscreen(win: BrowserWindow) {
const send = (fullscreen: boolean) => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
sendIpcEvent(win.webContents, Ipc.window.fullscreenChanged, fullscreen)
emitIpcEvent(win.webContents, new WindowFullscreenChanged({ fullscreen }))
}
win.on("enter-full-screen", () => send(true))
win.on("leave-full-screen", () => send(false))
}
function iconsDir() {
return app.isPackaged ? join(process.resourcesPath, "icons") : join(developmentResourcesRoot, "icons")
function iconsDir(path: Path.Path, paths: DesktopPaths.Resolved) {
return app.isPackaged
? path.join(process.resourcesPath, "icons")
: path.join(paths.developmentResourcesRoot, "icons")
}
function iconPath() {
return join(iconsDir(), `icon.${process.platform === "win32" ? "ico" : "png"}`)
function iconPath(path: Path.Path, paths: DesktopPaths.Resolved) {
return path.join(iconsDir(path, paths), `icon.${process.platform === "win32" ? "ico" : "png"}`)
}
function tone() {
@@ -148,5 +152,5 @@ function clampZoom(value: number) {
function updateZoom(win: BrowserWindow) {
updateTitlebar(win)
sendIpcEvent(win.webContents, Ipc.window.zoomFactorChanged, win.webContents.getZoomFactor())
emitIpcEvent(win.webContents, new WindowZoomChanged({ factor: win.webContents.getZoomFactor() }))
}
+86 -63
View File
@@ -1,10 +1,11 @@
import windowState from "electron-window-state"
import { randomUUID } from "node:crypto"
import { rmSync } from "node:fs"
import { join } from "node:path"
import { app, BrowserWindow } from "electron"
import { writeLog } from "../native/logging"
import { removeStoreFile, getStore } from "../storage/store"
import { Effect, FileSystem, Path } from "effect"
import { openExternalURL } from "../files"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { forgetStore, getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import {
getBackgroundColor,
@@ -20,7 +21,7 @@ import {
} from "./appearance"
import { loadWindow, registerRendererProtocol } from "./protocol"
import { createWindowRegistry } from "./registry"
import { wireWindowRecovery } from "./recovery"
import { makeWindowRecovery } from "./recovery"
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
const windowIDs = new WeakMap<BrowserWindow, string>()
@@ -28,10 +29,6 @@ const themeReady = new WeakMap<BrowserWindow, () => void>()
const registry = createWindowRegistry<BrowserWindow>({
read: () => getStore().get(WINDOW_IDS_KEY),
write: (ids) => getStore().set(WINDOW_IDS_KEY, ids),
cleanup: (id) => {
rmSync(join(app.getPath("userData"), windowStateFile(id)), { force: true })
removeStoreFile(windowDataFile(id))
},
})
let relaunchHandler = () => {
setAppQuitting()
@@ -51,7 +48,11 @@ export {
}
export function setRelaunchHandler(handler: () => void) {
const previous = relaunchHandler
relaunchHandler = handler
return () => {
if (relaunchHandler === handler) relaunchHandler = previous
}
}
export function setAppQuitting(quitting = true) {
@@ -74,63 +75,85 @@ export function setWindowThemeReady(win: BrowserWindow) {
themeReady.get(win)?.()
}
export function restoreMainWindows() {
const ids = registry.persisted()
return (ids.length ? ids : [randomUUID()]).map((id) => createMainWindow(id))
}
export const makeMainWindows = Effect.fn("Window.make")(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const runFork = Effect.runForkWith(yield* Effect.context())
const wireWindowRecovery = yield* makeWindowRecovery
export function createMainWindow(id: string = randomUUID()) {
const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 })
const win = new BrowserWindow({
x: state.x,
y: state.y,
width: state.width,
height: state.height,
show: false,
autoHideMenuBar: true,
...windowAppearance(),
})
allowRendererPermissions(win)
wireWindowRecovery(win, id, () => relaunchHandler())
wireNavigationPolicy(win)
wireRendererHeaders(win)
state.manage(win)
registerWindow(win, id)
wireFullscreen(win)
wireZoom(win)
let contentReady = false
let appliedTheme = false
let revealed = false
const reveal = () => {
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
revealed = true
win.show()
writeLog("window", "main window visible", { window: id })
const restore = () => {
const ids = registry.persisted()
return (ids.length ? ids : [randomUUID()]).map((id) => create(id))
}
const ready = () => {
contentReady = true
reveal()
}
themeReady.set(win, () => {
appliedTheme = true
reveal()
})
win.once("ready-to-show", ready)
if (process.platform === "linux") win.webContents.once("did-finish-load", ready)
win.once("closed", () => themeReady.delete(win))
loadWindow(win, "index.html")
return win
}
function registerWindow(win: BrowserWindow, id: string) {
windowIDs.set(win, id)
registry.register(id, win)
win.on("focus", () => registry.focused(id))
// Windows emits session-end, but not before-quit, during shutdown and logoff.
win.on("session-end", () => registry.setQuitting())
win.on("closed", () => registry.closed(id))
}
const create = (id: string = randomUUID()) => {
const state = windowState({ file: windowStateFile(id), defaultWidth: 1280, defaultHeight: 800 })
const win = new BrowserWindow({
x: state.x,
y: state.y,
width: state.width,
height: state.height,
show: false,
autoHideMenuBar: true,
...windowAppearance(path, paths),
})
allowRendererPermissions(win)
wireWindowRecovery(win, id, () => relaunchHandler())
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
wireRendererHeaders(win)
state.manage(win)
register(win, id)
wireFullscreen(win)
loadWindow(win, "index.html")
wireZoom(win)
let contentReady = false
let appliedTheme = false
let revealed = false
const reveal = () => {
if (!contentReady || !appliedTheme || revealed || win.isDestroyed()) return
revealed = true
win.show()
runFork(Effect.logInfo("main window visible", { window: id }))
}
const ready = () => {
contentReady = true
reveal()
}
themeReady.set(win, () => {
appliedTheme = true
reveal()
})
win.once("ready-to-show", ready)
if (process.platform === "linux") win.webContents.once("did-finish-load", ready)
win.once("closed", () => themeReady.delete(win))
return win
}
const register = (win: BrowserWindow, id: string) => {
windowIDs.set(win, id)
registry.register(id, win)
win.on("focus", () => registry.focused(id))
// Windows emits session-end, but not before-quit, during shutdown and logoff.
win.on("session-end", () => registry.setQuitting())
win.on("closed", () => {
if (!registry.closed(id)) return
const data = windowDataFile(id)
runFork(
Effect.gen(function* () {
yield* fs.remove(path.join(app.getPath("userData"), windowStateFile(id)), { force: true })
yield* fs.remove(path.join(app.getPath("userData"), data), { force: true })
}).pipe(
Effect.tap(() => Effect.sync(() => forgetStore(data))),
Effect.catch((error) => scoped("window", Effect.logError("failed to clean window files", { id, error }))),
),
)
})
}
return { create, restore }
})
function windowStateFile(id: string) {
return `window-state-${safeWindowID(id)}.json`
+24 -16
View File
@@ -1,9 +1,9 @@
import { net, protocol } from "electron"
import type { BrowserWindow } from "electron"
import { isAbsolute, relative, resolve } from "node:path"
import { pathToFileURL } from "node:url"
import { writeLog } from "../native/logging"
import { rendererRoot } from "../paths"
import { Effect, Path } from "effect"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
const rendererProtocol = "oc"
const rendererHost = "renderer"
@@ -22,20 +22,23 @@ protocol.registerSchemesAsPrivileged([
},
])
export function registerRendererProtocol() {
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const runFork = Effect.runForkWith(yield* Effect.context<never>())
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
writeLog("protocol", "rejected host", { url: request.url }, "warn")
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
return new Response("Not found", { status: 404 })
}
const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = relative(rendererRoot, file)
if (rel.startsWith("..") || isAbsolute(rel)) {
writeLog("protocol", "rejected path", { url: request.url, file }, "warn")
const file = path.resolve(paths.rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = path.relative(paths.rendererRoot, file)
if (rel.startsWith("..") || path.isAbsolute(rel)) {
runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file })))
return new Response("Not found", { status: 404 })
}
@@ -43,20 +46,25 @@ export function registerRendererProtocol() {
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
if (response.status >= 400) {
writeLog(
"protocol",
"fetch failed",
{ url: request.url, file, status: response.status, statusText: response.statusText },
"error",
runFork(
scoped(
"protocol",
Effect.logError("fetch failed", {
url: request.url,
file,
status: response.status,
statusText: response.statusText,
}),
),
)
}
return addDocumentPolicy(response, file)
} catch (error) {
writeLog("protocol", "fetch error", { url: request.url, file, error }, "error")
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
return new Response("Not found", { status: 404 })
}
})
}
})
export function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
+143 -113
View File
@@ -1,126 +1,156 @@
import { app, dialog } from "electron"
import type { BrowserWindow } from "electron"
import { exportDebugLogs, writeLog } from "../native/logging"
import { Effect } from "effect"
import { DesktopLogging, scoped } from "../native/logging"
import { nativeT } from "../native/translations"
import { safeWindowURL } from "./state"
import { createUnresponsiveSampler } from "./unresponsive"
import { makeUnresponsiveSampler } from "./unresponsive"
export function wireWindowRecovery(win: BrowserWindow, name: string, relaunch: () => void) {
let showing = false
const sampler = createUnresponsiveSampler(win, name)
export const makeWindowRecovery = Effect.gen(function* () {
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const runPromise = Effect.runPromiseWith(context)
const logging = yield* DesktopLogging.Service
const createUnresponsiveSampler = yield* makeUnresponsiveSampler
type RecoveryAction = "relaunch" | "export-logs" | "keep-waiting" | "quit"
const handle = async (action: RecoveryAction | undefined, wait: boolean) => {
if (action === "export-logs") {
const sampling = sampler.stopAndFlush()
await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error"))
if (wait && sampling) sampler.start()
return true
}
if (action === "relaunch") {
sampler.stopAndFlush()
relaunch()
function wireWindowRecovery(win: BrowserWindow, name: string, relaunch: () => void) {
let showing = false
const sampler = createUnresponsiveSampler(win, name)
type RecoveryAction = "relaunch" | "export-logs" | "keep-waiting" | "quit"
const handle = async (action: RecoveryAction | undefined, wait: boolean) => {
if (action === "export-logs") {
const sampling = sampler.stopAndFlush()
await runPromise(logging.exportDebug).catch((error) =>
runFork(Effect.logError("failed to export debug logs", { error })),
)
if (wait && sampling) sampler.start()
return true
}
if (action === "relaunch") {
sampler.stopAndFlush()
relaunch()
return false
}
if (action === "quit") {
sampler.stopAndFlush()
app.quit()
}
return false
}
if (action === "quit") {
sampler.stopAndFlush()
app.quit()
}
return false
}
const show = async (message: string, detail: string, wait: boolean) => {
if (showing || win.isDestroyed()) return
showing = true
try {
while (!win.isDestroyed()) {
const actions: { id: RecoveryAction; label: string }[] = wait
? [
{ id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") },
{ id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") },
{ id: "keep-waiting", label: nativeT("desktop.recovery.action.keepWaiting") },
]
: [
{ id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") },
{ id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") },
{ id: "quit", label: nativeT("desktop.recovery.action.quit") },
]
const result = await dialog.showMessageBox(win, {
type: "warning",
buttons: actions.map((action) => action.label),
defaultId: 0,
cancelId: 2,
message,
detail,
})
if (await handle(actions[result.response]?.id, wait)) continue
return
const show = async (message: string, detail: string, wait: boolean) => {
if (showing || win.isDestroyed()) return
showing = true
try {
while (!win.isDestroyed()) {
const actions: { id: RecoveryAction; label: string }[] = wait
? [
{ id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") },
{ id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") },
{ id: "keep-waiting", label: nativeT("desktop.recovery.action.keepWaiting") },
]
: [
{ id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") },
{ id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") },
{ id: "quit", label: nativeT("desktop.recovery.action.quit") },
]
const result = await dialog.showMessageBox(win, {
type: "warning",
buttons: actions.map((action) => action.label),
defaultId: 0,
cancelId: 2,
message,
detail,
})
if (await handle(actions[result.response]?.id, wait)) continue
return
}
} finally {
showing = false
}
} finally {
showing = false
}
const failed = (
event: string,
errorCode: number,
errorDescription: string,
validatedURL: string,
isMainFrame: boolean,
) => {
runFork(
scoped(
"window",
Effect.logError("renderer load failed", {
window: name,
event,
errorCode,
errorDescription,
validatedURL,
currentURL: safeWindowURL(win),
isMainFrame,
}),
),
)
if (!isMainFrame || errorCode === -3) return
void show(
nativeT("desktop.recovery.loadFailed"),
nativeT("desktop.recovery.loadFailed.detail", {
window: name,
url: validatedURL,
code: errorCode,
description: errorDescription,
}),
false,
)
}
win.webContents.on("did-fail-load", (_event, code, description, url, mainFrame) => {
failed("did-fail-load", code, description, url, mainFrame)
})
win.webContents.on("did-fail-provisional-load", (_event, code, description, url, mainFrame) => {
failed("did-fail-provisional-load", code, description, url, mainFrame)
})
win.webContents.on("render-process-gone", (_event, details) => {
sampler.stopAndFlush()
runFork(
scoped(
"window",
Effect.logError("renderer process gone", { window: name, currentURL: safeWindowURL(win), details }),
),
)
void show(
nativeT("desktop.recovery.terminated"),
nativeT("desktop.recovery.terminated.detail", {
window: name,
reason: details.reason,
code: details.exitCode ?? nativeT("desktop.recovery.unknown"),
}),
false,
)
})
win.on("unresponsive", () => {
runFork(
scoped("window", Effect.logError("renderer unresponsive", { window: name, currentURL: safeWindowURL(win) })),
)
sampler.start()
void show(nativeT("desktop.recovery.unresponsive"), nativeT("desktop.recovery.unresponsive.detail"), true)
})
win.on("responsive", () => {
runFork(
scoped("window", Effect.logError("renderer responsive", { window: name, currentURL: safeWindowURL(win) })),
)
sampler.stopAndFlush()
})
win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) {
runFork(scoped("pty", Effect.logInfo("console", { window: name, level, message, line, sourceId })))
}
})
win.webContents.on("preload-error", (_event, path, error) => {
runFork(scoped("preload", Effect.logError("preload error", { window: name, preloadPath: path, error })))
})
}
const failed = (
event: string,
errorCode: number,
errorDescription: string,
validatedURL: string,
isMainFrame: boolean,
) => {
writeLog(
"window",
"renderer load failed",
{ window: name, event, errorCode, errorDescription, validatedURL, currentURL: safeWindowURL(win), isMainFrame },
"error",
)
if (!isMainFrame || errorCode === -3) return
void show(
nativeT("desktop.recovery.loadFailed"),
nativeT("desktop.recovery.loadFailed.detail", {
window: name,
url: validatedURL,
code: errorCode,
description: errorDescription,
}),
false,
)
}
win.webContents.on("did-fail-load", (_event, code, description, url, mainFrame) => {
failed("did-fail-load", code, description, url, mainFrame)
})
win.webContents.on("did-fail-provisional-load", (_event, code, description, url, mainFrame) => {
failed("did-fail-provisional-load", code, description, url, mainFrame)
})
win.webContents.on("render-process-gone", (_event, details) => {
sampler.stopAndFlush()
writeLog("window", "renderer process gone", { window: name, currentURL: safeWindowURL(win), details }, "error")
void show(
nativeT("desktop.recovery.terminated"),
nativeT("desktop.recovery.terminated.detail", {
window: name,
reason: details.reason,
code: details.exitCode ?? nativeT("desktop.recovery.unknown"),
}),
false,
)
})
win.on("unresponsive", () => {
writeLog("window", "renderer unresponsive", { window: name, currentURL: safeWindowURL(win) }, "error")
sampler.start()
void show(nativeT("desktop.recovery.unresponsive"), nativeT("desktop.recovery.unresponsive.detail"), true)
})
win.on("responsive", () => {
writeLog("window", "renderer responsive", { window: name, currentURL: safeWindowURL(win) }, "error")
sampler.stopAndFlush()
})
win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) {
writeLog("pty", "console", { window: name, level, message, line, sourceId })
}
})
win.webContents.on("preload-error", (_event, path, error) => {
writeLog("preload", "preload error", { window: name, preloadPath: path, error }, "error")
})
}
return wireWindowRecovery
})
@@ -3,15 +3,13 @@ import { createWindowRegistry } from "./registry"
function setup(initial: unknown = []) {
const state = { stored: initial }
const cleaned: string[] = []
const registry = createWindowRegistry<{ name: string }>({
read: () => state.stored,
write: (ids) => {
state.stored = ids
},
cleanup: (id) => cleaned.push(id),
})
return { registry, state, cleaned }
return { registry, state }
}
describe("window registry", () => {
@@ -33,24 +31,21 @@ describe("window registry", () => {
const app = setup()
app.registry.register("a", { name: "a" })
app.registry.register("b", { name: "b" })
app.registry.closed("a")
expect(app.registry.closed("a")).toBe(true)
expect(app.state.stored).toEqual(["b"])
expect(app.cleaned).toEqual(["a"])
})
test("keeps the id when the last window closes so relaunch restores it", () => {
const app = setup()
app.registry.register("a", { name: "a" })
app.registry.closed("a")
expect(app.registry.closed("a")).toBe(false)
expect(app.state.stored).toEqual(["a"])
expect(app.cleaned).toEqual([])
const restarted = createWindowRegistry<{ name: string }>({
read: () => app.state.stored,
write: (ids) => {
app.state.stored = ids
},
cleanup: () => {},
})
expect(restarted.persisted()).toEqual(["a"])
})
@@ -60,10 +55,9 @@ describe("window registry", () => {
app.registry.register("a", { name: "a" })
app.registry.register("b", { name: "b" })
app.registry.setQuitting()
app.registry.closed("a")
app.registry.closed("b")
expect(app.registry.closed("a")).toBe(false)
expect(app.registry.closed("b")).toBe(false)
expect(app.state.stored).toEqual(["a", "b"])
expect(app.cleaned).toEqual([])
})
test("tracks the last focused window and falls back on close", () => {
@@ -84,8 +78,7 @@ describe("window registry", () => {
app.registry.register("b", { name: "b" })
app.registry.setQuitting()
app.registry.setQuitting(false)
app.registry.closed("a")
expect(app.registry.closed("a")).toBe(true)
expect(app.state.stored).toEqual(["b"])
expect(app.cleaned).toEqual(["a"])
})
})
@@ -3,7 +3,6 @@
export function createWindowRegistry<W>(persistence: {
read: () => unknown
write: (ids: string[]) => void
cleanup: (id: string) => void
}) {
const windows = new Map<string, W>()
let quitting = false
@@ -39,9 +38,9 @@ export function createWindowRegistry<W>(persistence: {
// forgets a window. Closing the last window quits the app and fires
// `closed` before `before-quit`, so treat it as a quit and keep the id
// for restore on next launch.
if (quitting || windows.size === 0) return
if (quitting || windows.size === 0) return false
persistence.write(persisted().filter((item) => item !== id))
persistence.cleanup(id)
return true
},
}
}
@@ -1,5 +1,4 @@
import type { BrowserWindow } from "electron"
import { openExternalURL } from "../files"
import { addRendererHeaders, isRendererUrl, upsertHeader } from "./protocol"
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
@@ -18,7 +17,7 @@ export function allowRendererPermissions(win: BrowserWindow) {
})
}
export function wireNavigationPolicy(win: BrowserWindow) {
export function wireNavigationPolicy(win: BrowserWindow, openExternalURL: (url: string) => unknown) {
win.webContents.setWindowOpenHandler(({ url }) => {
if (!isRendererUrl(url)) openExternalURL(url)
return { action: "deny" }
@@ -1,70 +1,77 @@
import type { BrowserWindow } from "electron"
import { writeLog } from "../native/logging"
import { Effect } from "effect"
import { scoped } from "../native/logging"
import { safeWindowURL } from "./state"
const sampleInterval = 1000
const samplePeriod = 15000
export function createUnresponsiveSampler(win: BrowserWindow, name: string) {
let sampleTimer: ReturnType<typeof setTimeout> | undefined
let stopTimer: ReturnType<typeof setTimeout> | undefined
let sampling = false
const samples = new Map<string, number>()
export const makeUnresponsiveSampler = Effect.gen(function* () {
const runFork = Effect.runForkWith(yield* Effect.context())
const active = () => sampling && !win.isDestroyed() && !win.webContents.isDestroyed()
const clearTimers = () => {
if (sampleTimer) clearTimeout(sampleTimer)
if (stopTimer) clearTimeout(stopTimer)
sampleTimer = undefined
stopTimer = undefined
function createUnresponsiveSampler(win: BrowserWindow, name: string) {
let sampleTimer: ReturnType<typeof setTimeout> | undefined
let stopTimer: ReturnType<typeof setTimeout> | undefined
let sampling = false
const samples = new Map<string, number>()
const active = () => sampling && !win.isDestroyed() && !win.webContents.isDestroyed()
const clearTimers = () => {
if (sampleTimer) clearTimeout(sampleTimer)
if (stopTimer) clearTimeout(stopTimer)
sampleTimer = undefined
stopTimer = undefined
}
const schedule = () => {
sampleTimer = setTimeout(() => {
void collect()
}, sampleInterval)
}
const collect = async () => {
if (!active()) return
const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => {
runFork(scoped("window", Effect.logError("failed to collect unresponsive sample", { window: name, error })))
return undefined
})
if (!active()) return
if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1)
schedule()
}
const stopAndFlush = () => {
const wasSampling = sampling
sampling = false
clearTimers()
if (samples.size === 0) return wasSampling
const entries = [...samples.entries()].sort((a, b) => b[1] - a[1])
const total = entries.reduce((sum, entry) => sum + entry[1], 0)
const message = [
"renderer unresponsive samples",
`Window: ${name}`,
`URL: ${safeWindowURL(win)}`,
...entries.map((entry) => `<${entry[1]}> ${entry[0]}`),
`Total Samples: ${total}`,
].join("\n")
runFork(scoped("window", Effect.logError(message)))
samples.clear()
return wasSampling
}
const start = () => {
if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return
sampling = true
samples.clear()
schedule()
stopTimer = setTimeout(stopAndFlush, samplePeriod)
}
win.on("closed", stopAndFlush)
return { start, stopAndFlush }
}
const schedule = () => {
sampleTimer = setTimeout(() => {
void collect()
}, sampleInterval)
}
const collect = async () => {
if (!active()) return
const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => {
writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error")
return undefined
})
if (!active()) return
if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1)
schedule()
}
const stopAndFlush = () => {
const wasSampling = sampling
sampling = false
clearTimers()
if (samples.size === 0) return wasSampling
const entries = [...samples.entries()].sort((a, b) => b[1] - a[1])
const total = entries.reduce((sum, entry) => sum + entry[1], 0)
const message = [
"renderer unresponsive samples",
`Window: ${name}`,
`URL: ${safeWindowURL(win)}`,
...entries.map((entry) => `<${entry[1]}> ${entry[0]}`),
`Total Samples: ${total}`,
].join("\n")
writeLog("window", message, undefined, "error")
samples.clear()
return wasSampling
}
const start = () => {
if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return
sampling = true
samples.clear()
schedule()
stopTimer = setTimeout(stopAndFlush, samplePeriod)
}
win.on("closed", stopAndFlush)
return { start, stopAndFlush }
}
return createUnresponsiveSampler
})
+72 -59
View File
@@ -1,27 +1,31 @@
export * as WslIpc from "./ipc"
import { app } from "electron"
import type { WebContents } from "electron"
import type { WslServerConfig, WslServersState } from "@opencode-ai/app/wsl/types"
import { Ipc, sendIpcEvent } from "../../shared/ipc-contract"
import { Effect } from "effect"
import { WslServersChanged } from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import type { WslServersController } from "./servers"
import { nativeT } from "../native/translations"
export type WslIpc = {
subscribe(sender: WebContents): void
unsubscribe(id: number): void
getState(): WslServersState
probeRuntime(): Promise<void>
refreshDistros(): Promise<void>
installWsl(): Promise<void>
installDistro(value: string): Promise<void>
probeAddable(value: string[]): Promise<void>
installOpencode(value: string): Promise<void>
openTerminal(value: string): Promise<void>
addServer(value: string): Promise<WslServerConfig>
removeServer(value: string): Promise<void>
startServer(value: string): Promise<void>
export interface Interface {
readonly subscribe: (sender: WebContents) => Effect.Effect<void>
readonly unsubscribe: (id: number) => Effect.Effect<void>
readonly getState: () => Effect.Effect<WslServersState>
readonly probeRuntime: () => Effect.Effect<void>
readonly refreshDistros: () => Effect.Effect<void>
readonly installWsl: () => Effect.Effect<void>
readonly installDistro: (value: string) => Effect.Effect<void>
readonly probeAddable: (value: string[]) => Effect.Effect<void>
readonly installOpencode: (value: string) => Effect.Effect<void>
readonly openTerminal: (value: string) => Effect.Effect<void>
readonly addServer: (value: string) => Effect.Effect<WslServerConfig>
readonly removeServer: (value: string) => Effect.Effect<void>
readonly startServer: (value: string) => Effect.Effect<void>
}
export function createWslIpc(controller?: WslServersController): WslIpc {
export function create(controller?: WslServersController): Interface {
if (!controller) return createUnavailableWslIpc()
const subscriptions = new Map<number, () => void>()
@@ -38,45 +42,53 @@ export function createWslIpc(controller?: WslServersController): WslIpc {
})
return {
subscribe(sender) {
const id = sender.id
if (subscriptions.has(id)) return
subscriptions.set(
id,
controller.subscribe((payload) => {
if (sender.isDestroyed()) {
unsubscribe(id)
return
}
sendIpcEvent(sender, Ipc.wsl.event, payload)
}),
)
sender.once("destroyed", () => unsubscribe(id))
},
unsubscribe,
getState: () => controller.getState(),
probeRuntime: () => controller.probeRuntime(),
refreshDistros: () => controller.refreshDistros(),
installWsl: () => controller.installWsl(),
installDistro: (value) => controller.installDistro(requireWslIpcString("distro", value)),
probeAddable: (value) => controller.probeAddable(requireWslIpcStrings("distro", value)),
installOpencode: (value) => controller.installOpencode(requireWslIpcString("distro", value)),
openTerminal: (value) => controller.openTerminal(requireWslIpcString("distro", value)),
addServer: (value) => controller.addServer(requireWslIpcString("distro", value)),
removeServer: (value) => controller.removeServer(requireWslIpcString("server id", value)),
startServer: (value) => controller.startServer(requireWslIpcString("server id", value)),
subscribe: (sender) =>
Effect.sync(() => {
const id = sender.id
if (subscriptions.has(id)) return
subscriptions.set(
id,
controller.subscribe((payload) => {
if (sender.isDestroyed()) {
unsubscribe(id)
return
}
emitIpcEvent(sender, new WslServersChanged({ event: payload }))
}),
)
sender.once("destroyed", () => unsubscribe(id))
}),
unsubscribe: (id) => Effect.sync(() => unsubscribe(id)),
getState: () => Effect.sync(() => controller.getState()),
probeRuntime: () => promise(() => controller.probeRuntime()),
refreshDistros: () => promise(() => controller.refreshDistros()),
installWsl: () => promise(() => controller.installWsl()),
installDistro: (value) => promise(() => controller.installDistro(requireWslIpcString("distro", value))),
probeAddable: (value) => promise(() => controller.probeAddable(requireWslIpcStrings("distro", value))),
installOpencode: (value) => promise(() => controller.installOpencode(requireWslIpcString("distro", value))),
openTerminal: (value) => promise(() => controller.openTerminal(requireWslIpcString("distro", value))),
addServer: (value) => promise(() => controller.addServer(requireWslIpcString("distro", value))),
removeServer: (value) => promise(() => controller.removeServer(requireWslIpcString("server id", value))),
startServer: (value) => promise(() => controller.startServer(requireWslIpcString("server id", value))),
}
}
function createUnavailableWslIpc(): WslIpc {
function promise<A>(evaluate: () => Promise<A>) {
return Effect.tryPromise(evaluate).pipe(Effect.orDie)
}
function createUnavailableWslIpc(): Interface {
const message = nativeT(
process.platform === "win32" ? "desktop.wsl.error.unavailable" : "desktop.wsl.error.windowsOnly",
)
const unavailable = () => {
throw new Error(nativeT("desktop.wsl.error.windowsOnly"))
throw new Error(message)
}
const state = (): WslServersState => ({
runtime: {
available: false,
version: null,
error: nativeT("desktop.wsl.error.windowsOnly"),
error: message,
},
installed: [],
online: [],
@@ -88,19 +100,20 @@ function createUnavailableWslIpc(): WslIpc {
})
return {
subscribe: (sender) => sendIpcEvent(sender, Ipc.wsl.event, { type: "state", state: state() }),
unsubscribe: () => undefined,
getState: state,
probeRuntime: unavailable,
refreshDistros: unavailable,
installWsl: unavailable,
installDistro: unavailable,
probeAddable: unavailable,
installOpencode: unavailable,
openTerminal: unavailable,
addServer: unavailable,
removeServer: unavailable,
startServer: unavailable,
subscribe: (sender) =>
Effect.sync(() => emitIpcEvent(sender, new WslServersChanged({ event: { type: "state", state: state() } }))),
unsubscribe: () => Effect.void,
getState: () => Effect.sync(state),
probeRuntime: () => Effect.sync(unavailable),
refreshDistros: () => Effect.sync(unavailable),
installWsl: () => Effect.sync(unavailable),
installDistro: () => Effect.sync(unavailable),
probeAddable: () => Effect.sync(unavailable),
installOpencode: () => Effect.sync(unavailable),
openTerminal: () => Effect.sync(unavailable),
addServer: () => Effect.sync(unavailable),
removeServer: () => Effect.sync(unavailable),
startServer: () => Effect.sync(unavailable),
}
}
+41 -31
View File
@@ -1,38 +1,48 @@
import { execFile } from "node:child_process"
import { copyFile, mkdtemp, readFile, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { promisify } from "node:util"
import { Effect, FileSystem, Path } from "effect"
const execFileAsync = promisify(execFile)
export async function buildLocalWslCli(input: { version: string; script: string; output: string }) {
const directory = await mkdtemp(join(tmpdir(), "opencode-wsl-cli-"))
const root = join(dirname(input.script), "../../..")
const packageManager = (JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { packageManager: string })
.packageManager
const target = `linux-${process.arch}`
try {
await execFileAsync("bunx", [packageManager, "install", "--os=*", "--cpu=*", "--frozen-lockfile"], {
cwd: root,
env: process.env,
windowsHide: true,
})
await execFileAsync(
"bunx",
[
packageManager,
input.script,
`--target=opencode2-${target}`,
"--skip-install",
"--skip-web-ui",
`--outdir=${directory}`,
],
{ cwd: root, env: { ...process.env, OPENCODE_VERSION: input.version }, windowsHide: true },
export const buildLocalWslCli = Effect.fn("Wsl.buildLocalCli")(function* (input: {
version: string
script: string
output: string
}) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const directory = yield* fs.makeTempDirectory({ prefix: "opencode-wsl-cli-" })
const build = Effect.gen(function* () {
const root = path.join(path.dirname(input.script), "../../..")
const packageManager = (
JSON.parse(yield* fs.readFileString(path.join(root, "package.json"))) as {
packageManager: string
}
).packageManager
const target = `linux-${process.arch}`
yield* Effect.tryPromise(() =>
execFileAsync("bunx", [packageManager, "install", "--os=*", "--cpu=*", "--frozen-lockfile"], {
cwd: root,
env: process.env,
windowsHide: true,
}),
)
await copyFile(join(directory, `cli-${target}`, "bin", "opencode2"), input.output)
yield* Effect.tryPromise(() =>
execFileAsync(
"bunx",
[
packageManager,
input.script,
`--target=opencode2-${target}`,
"--skip-install",
"--skip-web-ui",
`--outdir=${directory}`,
],
{ cwd: root, env: { ...process.env, OPENCODE_VERSION: input.version }, windowsHide: true },
),
)
yield* fs.copyFile(path.join(directory, `cli-${target}`, "bin", "opencode2"), input.output)
return input.output
} finally {
await rm(directory, { recursive: true, force: true })
}
}
})
return yield* build.pipe(Effect.ensuring(fs.remove(directory, { recursive: true, force: true }).pipe(Effect.orDie)))
})
+31 -20
View File
@@ -1,8 +1,7 @@
import { spawn } from "node:child_process"
import { existsSync } from "node:fs"
import { join } from "node:path"
import * as pty from "@lydell/node-pty"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "@opencode-ai/app/wsl/types"
import { Effect, FileSystem, Path } from "effect"
import { nativeT } from "../native/translations"
import { parseCliVersion } from "../service/cli-version"
@@ -261,25 +260,35 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) {
requireSuccess(result, nativeT("desktop.wsl.error.installWsl"))
}
export async function installWslDistro(distro: string, opts?: RunWslOptions) {
const result = await runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
["--install", "-d", distro, "--web-download", "--no-launch"],
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
export const installWslDistro = Effect.fn("Wsl.installDistro")(function* (distro: string, opts?: RunWslOptions) {
const command = yield* resolveSystem32Command("wsl.exe")
const result = yield* Effect.tryPromise(() =>
runInteractiveCommand(
command,
["--install", "-d", distro, "--web-download", "--no-launch"],
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
),
)
requireSuccess(result, nativeT("desktop.wsl.error.installDistro", { distro }))
}
})
export async function installWslCli(distro: string, cli: WslCliBuild, opts?: RunWslOptions) {
const result = await runInteractiveCommand(
resolveSystem32Command("wsl.exe"),
wslArgs(["bash", "-lc", wslCliInstallCommand(cli)], distro),
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
export const installWslCli = Effect.fn("Wsl.installCli")(function* (
distro: string,
cli: WslCliBuild,
opts?: RunWslOptions,
) {
const command = yield* resolveSystem32Command("wsl.exe")
const result = yield* Effect.tryPromise(() =>
runInteractiveCommand(
command,
wslArgs(["bash", "-lc", wslCliInstallCommand(cli)], distro),
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
),
)
requireSuccess(result, nativeT("desktop.wsl.error.installOpencode"))
}
})
export function wslCliInstallCommand(cli: WslCliBuild) {
const installer = "curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/v2/install | bash -s --"
@@ -407,12 +416,14 @@ export function shellEscape(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}
function resolveSystem32Command(command: string) {
const resolveSystem32Command = Effect.fn("Wsl.resolveSystem32Command")(function* (command: string) {
const root = process.env.SystemRoot ?? process.env.windir
if (!root) return command
const resolved = join(root, "System32", command)
return existsSync(resolved) ? resolved : command
}
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const resolved = path.join(root, "System32", command)
return (yield* fs.exists(resolved).pipe(Effect.orElseSucceed(() => false))) ? resolved : command
})
function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions {
return {
+100 -58
View File
@@ -1,5 +1,6 @@
import { expect, test } from "bun:test"
import type { WslServerConfig } from "@opencode-ai/app/wsl/types"
import { Effect } from "effect"
import { wslCliInstallCommand } from "./runtime"
import { createWslServersController } from "./servers"
@@ -16,13 +17,15 @@ test("passes a local CLI path directly to the V2 installer", () => {
test("installs and verifies the bundled CLI version", async () => {
persistedServers = []
const installs: string[][] = []
const controller = createWslServersController(
testControllerOptions({
installCli: async (distro, cli) => {
installs.push([distro, cli.version])
},
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
}),
const controller = await Effect.runPromise(
createWslServersController(
testControllerOptions({
installCli: async (distro, cli) => {
installs.push([distro, cli.version])
},
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
}),
),
)
await controller.installOpencode("Debian")
@@ -33,12 +36,14 @@ test("installs and verifies the bundled CLI version", async () => {
test("rejects a WSL CLI version that differs from the bundled version", async () => {
persistedServers = []
const controller = createWslServersController(
testControllerOptions({
installCli: async () => undefined,
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
readCliVersion: async () => "0.0.0-dev-older",
}),
const controller = await Effect.runPromise(
createWslServersController(
testControllerOptions({
installCli: async () => undefined,
resolveCli: async () => "/home/me/.opencode/bin/opencode2",
readCliVersion: async () => "0.0.0-dev-older",
}),
),
)
await expect(controller.installOpencode("Debian")).rejects.toThrow(
@@ -49,24 +54,26 @@ test("rejects a WSL CLI version that differs from the bundled version", async ()
test("stops a running WSL server before replacing its CLI", async () => {
persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
const events: string[] = []
const controller = createWslServersController(
testControllerOptions({
spawnSidecar: async () => {
events.push("start")
return {
stop: async () => {
events.push("stop")
},
onExit: () => undefined,
url: "http://127.0.0.1:4096",
username: "opencode",
password: "secret",
}
},
installCli: async () => {
events.push("install")
},
}),
const controller = await Effect.runPromise(
createWslServersController(
testControllerOptions({
spawnSidecar: async () => {
events.push("start")
return {
stop: async () => {
events.push("stop")
},
onExit: () => undefined,
url: "http://127.0.0.1:4096",
username: "opencode",
password: "secret",
}
},
installCli: async () => {
events.push("install")
},
}),
),
)
controller.startConfiguredServers()
await waitFor(() => controller.getState().servers[0]?.runtime.kind === "ready")
@@ -77,24 +84,55 @@ test("stops a running WSL server before replacing its CLI", async () => {
await controller.stopServers()
})
test("stops a sidecar that finishes starting after shutdown", async () => {
persistedServers = [{ id: "wsl:Debian", distro: "Debian" }]
const stopped: string[] = []
let resolveSidecar: ((sidecar: Awaited<ReturnType<ControllerOptions["spawnSidecar"]>>) => void) | undefined
const controller = await Effect.runPromise(
createWslServersController(
testControllerOptions({
spawnSidecar: () => new Promise((resolve) => (resolveSidecar = resolve)),
}),
),
)
controller.startConfiguredServers()
await waitFor(() => controller.getState().servers[0]?.runtime.kind === "starting")
await controller.stopServers()
resolveSidecar?.({
stop: async () => {
stopped.push("stop")
},
onExit: () => undefined,
url: "http://127.0.0.1:4096",
username: "opencode",
password: "secret",
})
await waitFor(() => stopped.length === 1)
expect(stopped).toEqual(["stop"])
})
test("probes addable distros in parallel before checking OpenCode", async () => {
persistedServers = []
const started: string[] = []
const release = new Map<string, () => void>()
const opencode: string[] = []
const controller = createWslServersController(
testControllerOptions({
spawnSidecar: pendingSidecar,
probeDistro: async (distro) => {
started.push(distro)
await new Promise<void>((resolve) => release.set(distro, resolve))
return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null }
},
resolveCli: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode2"
},
}),
const controller = await Effect.runPromise(
createWslServersController(
testControllerOptions({
spawnSidecar: pendingSidecar,
probeDistro: async (distro) => {
started.push(distro)
await new Promise<void>((resolve) => release.set(distro, resolve))
return { name: distro, canExecute: true, hasBash: true, hasCurl: true, error: null }
},
resolveCli: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode2"
},
}),
),
)
const task = controller.probeAddable(["Debian", "Ubuntu"])
@@ -113,21 +151,23 @@ test("probes addable distros in parallel before checking OpenCode", async () =>
test("does not check OpenCode in addable distros that cannot execute commands", async () => {
persistedServers = []
const opencode: string[] = []
const controller = createWslServersController(
testControllerOptions({
spawnSidecar: pendingSidecar,
probeDistro: async (distro) => ({
name: distro,
canExecute: distro === "Debian",
hasBash: distro === "Debian",
hasCurl: distro === "Debian",
error: distro === "Debian" ? null : "Open Ubuntu once to finish setup",
const controller = await Effect.runPromise(
createWslServersController(
testControllerOptions({
spawnSidecar: pendingSidecar,
probeDistro: async (distro) => ({
name: distro,
canExecute: distro === "Debian",
hasBash: distro === "Debian",
hasCurl: distro === "Debian",
error: distro === "Debian" ? null : "Open Ubuntu once to finish setup",
}),
resolveCli: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode2"
},
}),
resolveCli: async (distro) => {
opencode.push(distro)
return "/home/me/.opencode/bin/opencode2"
},
}),
),
)
await controller.probeAddable(["Debian", "Ubuntu"])
@@ -148,6 +188,8 @@ async function waitFor(check: () => boolean) {
function testControllerOptions(overrides: Partial<ControllerOptions> = {}): ControllerOptions {
return {
cli: { version: "0.0.0-dev-16365" },
installCli: async () => undefined,
installDistro: async () => undefined,
spawnSidecar: async () => ({
stop: async () => undefined,
onExit: () => undefined,
+32 -19
View File
@@ -8,12 +8,11 @@ import type {
WslServersEvent,
WslServersState,
} from "@opencode-ai/app/wsl/types"
import { Effect } from "effect"
import { nativeT } from "../native/translations"
import { WSL_SERVERS_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import {
installWslCli,
installWslDistro,
installWslRuntimeElevated,
listInstalledWslDistros,
listOnlineWslDistros,
@@ -35,33 +34,33 @@ type RunningSidecar = {
type SpawnSidecar = (distro: string) => Promise<RunningSidecar>
type ControllerLogger = {
log: (message: string, meta?: unknown) => void
error: (message: string, meta?: unknown) => void
}
type WslServersControllerOptions = {
cli: WslCliBuild
spawnSidecar: SpawnSidecar
logger?: ControllerLogger
installCli: (distro: string, cli: WslCliBuild) => Promise<void>
installDistro: (distro: string) => Promise<void>
readServers?: () => WslServerConfig[]
writeServers?: (servers: WslServerConfig[]) => void
installCli?: typeof installWslCli
probeDistro?: typeof probeWslDistro
resolveCli?: typeof resolveWslCli
readCliVersion?: typeof readWslCliVersion
}
export type WslServersController = ReturnType<typeof createWslServersController>
export type WslServersController = Effect.Success<ReturnType<typeof createWslServersController>>
export function wslServerIdForDistro(distro: string) {
return `wsl:${distro}`
}
export function createWslServersController(options: WslServersControllerOptions) {
export const createWslServersController = Effect.fn("WslServers.make")(function* (
options: WslServersControllerOptions,
) {
const runFork = Effect.runForkWith(yield* Effect.context())
let state: WslServersState = initialState()
const listeners = new Set<(event: WslServersEvent) => void>()
const sidecars = new Map<string, RunningSidecar>()
const starts = new Map<string, symbol>()
let closed = false
const readServers = options.readServers ?? readPersistedServers
const writeServers = options.writeServers ?? writePersistedServers
const probeDistro = options.probeDistro ?? probeWslDistro
@@ -142,7 +141,7 @@ export function createWslServersController(options: WslServersControllerOptions)
const refreshCliCheckSafely = (id: string, distro: string) => {
return refreshCliCheck(distro).catch((error) => {
const message = error instanceof Error ? error.message : String(error)
options.logger?.error("wsl CLI check failed", { id, distro, message })
runFork(Effect.logError("wsl CLI check failed", { id, distro, message }))
})
}
@@ -159,10 +158,18 @@ export function createWslServersController(options: WslServersControllerOptions)
const item = state.servers.find((x) => x.config.id === id)
if (!item) return
await stopServer(id)
if (closed) return
const token = Symbol()
starts.set(id, token)
setRuntime(id, { kind: "starting" })
options.logger?.log("wsl sidecar starting", { id, distro: item.config.distro })
runFork(Effect.logInfo("wsl sidecar starting", { id, distro: item.config.distro }))
try {
const sidecar = await options.spawnSidecar(item.config.distro)
if (starts.get(id) !== token) {
await sidecar.stop()
return
}
starts.delete(id)
sidecars.set(id, sidecar)
setRuntime(id, {
kind: "ready",
@@ -175,18 +182,21 @@ export function createWslServersController(options: WslServersControllerOptions)
sidecars.delete(id)
const message = startupFailure(code, signal)
setRuntime(id, { kind: "failed", message })
options.logger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
runFork(Effect.logError("wsl sidecar exited", { id, distro: item.config.distro, code, signal }))
})
void refreshCliCheckSafely(id, item.config.distro)
options.logger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
runFork(Effect.logInfo("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url }))
} catch (error) {
if (starts.get(id) !== token) return
starts.delete(id)
const message = error instanceof Error ? error.message : String(error)
setRuntime(id, { kind: "failed", message })
options.logger?.error("wsl sidecar failed to start", { id, distro: item.config.distro, message })
runFork(Effect.logError("wsl sidecar failed to start", { id, distro: item.config.distro, message }))
}
}
const stopServer = async (id: string) => {
starts.delete(id)
const existing = sidecars.get(id)
if (!existing) return
sidecars.delete(id)
@@ -213,6 +223,7 @@ export function createWslServersController(options: WslServersControllerOptions)
},
startConfiguredServers() {
closed = false
refreshFromStore()
void refreshCliChecks()
state.servers.forEach((item) => void startServer(item.config.id))
@@ -244,7 +255,7 @@ export function createWslServersController(options: WslServersControllerOptions)
async installDistro(distro: string) {
await runJob({ kind: "install-distro", distro, startedAt: Date.now() }, async () => {
await installWslDistro(distro)
await options.installDistro(distro)
const distros = await refreshDistroLists()
const probe = await probeDistro(distro)
setState({
@@ -263,7 +274,7 @@ export function createWslServersController(options: WslServersControllerOptions)
await runJob({ kind: "install-opencode", distro, startedAt: Date.now() }, async () => {
const id = state.servers.find((item) => item.config.distro === distro)?.config.id
if (id) await stopServer(id)
await (options.installCli ?? installWslCli)(distro, options.cli)
await options.installCli(distro, options.cli)
requireMatchingCli(await refreshCliCheck(distro), options.cli.version)
if (id) await startServer(id)
})
@@ -304,11 +315,13 @@ export function createWslServersController(options: WslServersControllerOptions)
startServer,
async stopServers() {
closed = true
starts.clear()
await Promise.all([...sidecars.values()].map((sidecar) => sidecar.stop()))
sidecars.clear()
},
}
}
})
function initialState(): WslServersState {
return {
+47 -31
View File
@@ -1,47 +1,63 @@
import { createWslIpc } from "./ipc"
export * as Wsl from "./start"
type Cli = {
version: string
wslBuild?: { script: string; output: string }
import { Context, Effect, Exit, FileSystem, Layer, Path } from "effect"
import { Shutdown } from "../lifecycle/shutdown"
import { DesktopCli } from "../service/desktop-cli"
import { WslIpc } from "./ipc"
export interface Interface extends WslIpc.Interface {
readonly stop: Effect.Effect<void>
}
type Logger = {
log(message: string, meta?: unknown): void
error(message: string, meta?: unknown): void
}
export class Service extends Context.Service<Service, Interface>()("opencode/desktop/Wsl") {}
export async function startWsl(cli: Cli, logger: Logger) {
if (process.platform !== "win32") return { ipc: createWslIpc(), start: () => {}, stop: async () => {} }
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const desktopCli = yield* DesktopCli.Service
const cli = yield* desktopCli.resolve.pipe(Effect.exit)
const wsl = Exit.isSuccess(cli) ? yield* makeWsl(cli.value) : { ...WslIpc.create(), stop: Effect.void }
const shutdown = yield* Shutdown.Service
const removeShutdown = yield* shutdown.add(wsl.stop)
yield* Effect.addFinalizer(() => Effect.sync(removeShutdown).pipe(Effect.andThen(wsl.stop)))
return Service.of(wsl)
}),
)
const { createWslServersController } = await import("./servers")
const { spawnWslSidecar } = await import("./sidecar")
const makeWsl = Effect.fn("Wsl.make")(function* (cli: DesktopCli.Resolved) {
if (process.platform !== "win32") return { ...WslIpc.create(), stop: Effect.void }
const { createWslServersController } = yield* Effect.promise(() => import("./servers"))
const { spawnWslSidecar } = yield* Effect.promise(() => import("./sidecar"))
const { installWslCli, installWslDistro } = yield* Effect.promise(() => import("./runtime"))
const context = yield* Effect.context<FileSystem.FileSystem | Path.Path>()
const run = Effect.runPromiseWith(context)
const runFork = Effect.runForkWith(context)
const local = cli.wslBuild
const controller = createWslServersController({
const controller = yield* createWslServersController({
cli: { version: cli.version },
installDistro: (distro) => run(installWslDistro(distro)),
installCli: local
? async (distro) => {
const { buildLocalWslCli } = await import("./local")
const { installWslCli } = await import("./runtime")
await installWslCli(distro, {
version: cli.version,
binary: await buildLocalWslCli({ ...local, version: cli.version }),
})
await run(
Effect.gen(function* () {
const binary = yield* buildLocalWslCli({ ...local, version: cli.version })
yield* installWslCli(distro, { version: cli.version, binary })
}),
)
}
: undefined,
spawnSidecar: async (distro) => {
logger.log("spawning wsl sidecar", { distro })
: (distro, build) => run(installWslCli(distro, build)),
spawnSidecar: (distro) => {
runFork(Effect.logInfo("spawning wsl sidecar", { distro }))
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
onLine: (line) => runFork(Effect.logInfo("wsl sidecar", { distro, stream: line.stream, text: line.text })),
})
},
logger: {
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
})
controller.startConfiguredServers()
return {
ipc: createWslIpc(controller),
start: () => controller.startConfiguredServers(),
stop: () => controller.stopServers(),
}
}
...WslIpc.create(controller),
stop: Effect.tryPromise(() => controller.stopServers()).pipe(Effect.orDie),
} satisfies Interface
})
+8 -143
View File
@@ -1,146 +1,11 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import type { IpcRendererEvent } from "electron"
import type { ElectronAPI } from "./types"
import type { UpdaterState } from "@opencode-ai/app/updater"
import {
Ipc,
type IpcEvent,
type IpcEventListener,
type IpcInvoke,
type IpcInvokeArgs,
type IpcInvokeResult,
type IpcSend,
} from "../shared/ipc-contract"
import { IpcTransportPort } from "../shared/ipc-transport"
function invoke<Channel extends keyof IpcInvoke>(channel: Channel, ...args: IpcInvokeArgs<Channel>) {
return ipcRenderer.invoke(channel, ...args) as Promise<IpcInvokeResult<Channel>>
}
ipcRenderer.on(IpcTransportPort, (event) => {
const port = event.ports[0]
if (port) window.postMessage(IpcTransportPort, "*", [port])
})
function send<Channel extends keyof IpcSend>(channel: Channel, ...args: IpcSend[Channel]) {
ipcRenderer.send(channel, ...args)
}
function listen<Channel extends keyof IpcEvent>(channel: Channel, listener: IpcEventListener<Channel>) {
const handler = (_event: IpcRendererEvent, ...args: IpcEvent[Channel]) => listener(...args)
ipcRenderer.on(channel, handler)
return () => ipcRenderer.removeListener(channel, handler)
}
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
let updaterState: UpdaterState | undefined
let updaterSubscription: Promise<void> | undefined
let updaterListener: (() => void) | undefined
const updaterHandler = (state: UpdaterState) => {
updaterState = state
updaterCallbacks.forEach((callback) => callback(state))
}
type WslInvoke = Exclude<
(typeof Ipc.wsl)[keyof typeof Ipc.wsl],
typeof Ipc.wsl.awaitInitialization | typeof Ipc.wsl.event
>
function invokeWsl<Channel extends WslInvoke>(channel: Channel, ...args: IpcInvokeArgs<Channel>) {
return invoke(Ipc.wsl.awaitInitialization).then(() => invoke(channel, ...args))
}
const api: ElectronAPI = {
awaitInitialization: () => invoke(Ipc.app.awaitInitialization),
wslServers: {
getState: () => invokeWsl(Ipc.wsl.getState),
subscribe: (cb) => {
const dispose = listen(Ipc.wsl.event, cb)
const subscribed = invokeWsl(Ipc.wsl.subscribe)
return () => {
dispose()
void subscribed.then(() => invokeWsl(Ipc.wsl.unsubscribe))
}
},
probeRuntime: () => invokeWsl(Ipc.wsl.probeRuntime),
refreshDistros: () => invokeWsl(Ipc.wsl.refreshDistros),
installWsl: () => invokeWsl(Ipc.wsl.installWsl),
installDistro: (name) => invokeWsl(Ipc.wsl.installDistro, name),
probeAddable: (distros) => invokeWsl(Ipc.wsl.probeAddable, distros),
installOpencode: (name) => invokeWsl(Ipc.wsl.installOpencode, name),
openTerminal: (name) => invokeWsl(Ipc.wsl.openTerminal, name),
addServer: (distro) => invokeWsl(Ipc.wsl.addServer, distro),
removeServer: (id) => invokeWsl(Ipc.wsl.removeServer, id),
startServer: (id) => invokeWsl(Ipc.wsl.startServer, id),
},
updater: {
subscribe: async (cb) => {
updaterCallbacks.add(cb)
if (updaterState) cb(updaterState)
if (!updaterSubscription) {
updaterListener = listen(Ipc.updater.state, updaterHandler)
updaterSubscription = invoke(Ipc.updater.subscribe)
}
await updaterSubscription
return () => {
updaterCallbacks.delete(cb)
if (updaterCallbacks.size > 0) return
updaterListener?.()
updaterListener = undefined
updaterSubscription = undefined
void invoke(Ipc.updater.unsubscribe)
}
},
check: () => invoke(Ipc.updater.check),
install: () => invoke(Ipc.updater.install),
},
consumeInitialDeepLinks: () => invoke(Ipc.app.consumeInitialDeepLinks),
getDefaultServerUrl: () => invoke(Ipc.app.getDefaultServerUrl),
setDefaultServerUrl: (url) => invoke(Ipc.app.setDefaultServerUrl, url),
isFirstLaunchOnboardingPending: () => invoke(Ipc.app.isFirstLaunchOnboardingPending),
finishFirstLaunchOnboarding: (createDefaultProject) =>
invoke(Ipc.app.finishFirstLaunchOnboarding, createDefaultProject),
checkAppExists: (appName) => invoke(Ipc.app.checkAppExists, appName),
resolveAppPath: (appName) => invoke(Ipc.app.resolveAppPath, appName),
storeGet: (name, key) => invoke(Ipc.storage.get, name, key),
storeSet: (name, key, value) => invoke(Ipc.storage.set, name, key, value),
storeDelete: (name, key) => invoke(Ipc.storage.delete, name, key),
storeClear: (name) => invoke(Ipc.storage.clear, name),
storeKeys: (name) => invoke(Ipc.storage.keys, name),
storeLength: (name) => invoke(Ipc.storage.length, name),
draftGet: (key) => invoke(Ipc.drafts.get, key),
draftSet: (key, value) => invoke(Ipc.drafts.set, key, value),
draftDelete: (key) => invoke(Ipc.drafts.delete, key),
draftBlobPut: (data) => invoke(Ipc.drafts.putBlob, data),
draftBlobGet: (id) => invoke(Ipc.drafts.getBlob, id),
getWindowID: () => invoke(Ipc.window.getId),
themeReady: () => invoke(Ipc.window.themeReady),
onMenuCommand: (cb) => listen(Ipc.menu.command, cb),
onDeepLink: (cb) => listen(Ipc.app.deepLink, cb),
openDirectoryPicker: (opts) => invoke(Ipc.files.openDirectoryPicker, opts),
openFilePicker: (opts) => invoke(Ipc.files.openFilePicker, opts),
readPickedFile: (token, path) => invoke(Ipc.files.readPickedFile, token, path),
releasePickedFiles: (token) => invoke(Ipc.files.releasePickedFiles, token),
getPathForFile: (file) => webUtils.getPathForFile(file),
saveFilePicker: (opts) => invoke(Ipc.files.saveFilePicker, opts),
openExternal: (url) => send(Ipc.files.openExternal, url),
openLocalFile: (url) => send(Ipc.files.openLocalFile, url),
openPath: (path, app) => invoke(Ipc.files.openPath, path, app),
revealPath: (path) => invoke(Ipc.files.revealPath, path),
readClipboardImage: () => invoke(Ipc.files.readClipboardImage),
getWindowFocused: () => invoke(Ipc.window.getFocused),
getWindowFullscreen: () => invoke(Ipc.window.getFullscreen),
onWindowFullscreenChanged: (cb) => listen(Ipc.window.fullscreenChanged, cb),
setWindowFocus: () => invoke(Ipc.window.setFocus),
showWindow: () => invoke(Ipc.window.show),
relaunch: () => send(Ipc.app.relaunch),
getZoomFactor: () => invoke(Ipc.window.getZoomFactor),
setZoomFactor: (factor) => invoke(Ipc.window.setZoomFactor, factor),
getPinchZoomEnabled: () => invoke(Ipc.window.getPinchZoomEnabled),
setPinchZoomEnabled: (enabled) => invoke(Ipc.window.setPinchZoomEnabled, enabled),
onPinchZoomEnabledChanged: (cb) => listen(Ipc.window.pinchZoomEnabledChanged, cb),
onZoomFactorChanged: (cb) => listen(Ipc.window.zoomFactorChanged, cb),
setTitlebar: (theme) => invoke(Ipc.window.setTitlebar, theme),
runDesktopMenuAction: (action) => invoke(Ipc.menu.runAction, action),
setBackgroundColor: (color) => invoke(Ipc.app.setBackgroundColor, color),
exportDebugLogs: () => invoke(Ipc.app.exportDebugLogs),
setForceFocus: (enabled) => invoke(Ipc.app.setForceFocus, enabled),
recordFatalRendererError: (error) => invoke(Ipc.app.recordFatalRendererError, error),
setNativeTranslations: (bundle) => invoke(Ipc.app.setNativeTranslations, bundle),
}
contextBridge.exposeInMainWorld("api", api)
contextBridge.exposeInMainWorld("electron", {
getPathForFile: (file: File) => webUtils.getPathForFile(file),
})
+2 -74
View File
@@ -1,75 +1,3 @@
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import {
Ipc,
type IpcEventListener,
type IpcEventSubscription,
type IpcInvokeMethod,
type IpcSendMethod,
} from "../shared/ipc-contract"
export type WslServersAPI = WslServersPlatform
export type UpdaterAPI = {
subscribe: (cb: IpcEventListener<typeof Ipc.updater.state>) => Promise<() => void>
check: IpcInvokeMethod<typeof Ipc.updater.check>
install: IpcInvokeMethod<typeof Ipc.updater.install>
}
export type ElectronAPI = {
awaitInitialization: IpcInvokeMethod<typeof Ipc.app.awaitInitialization>
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks: IpcInvokeMethod<typeof Ipc.app.consumeInitialDeepLinks>
getDefaultServerUrl: IpcInvokeMethod<typeof Ipc.app.getDefaultServerUrl>
setDefaultServerUrl: IpcInvokeMethod<typeof Ipc.app.setDefaultServerUrl>
isFirstLaunchOnboardingPending: IpcInvokeMethod<typeof Ipc.app.isFirstLaunchOnboardingPending>
finishFirstLaunchOnboarding: IpcInvokeMethod<typeof Ipc.app.finishFirstLaunchOnboarding>
checkAppExists: IpcInvokeMethod<typeof Ipc.app.checkAppExists>
resolveAppPath: IpcInvokeMethod<typeof Ipc.app.resolveAppPath>
storeGet: IpcInvokeMethod<typeof Ipc.storage.get>
storeSet: IpcInvokeMethod<typeof Ipc.storage.set>
storeDelete: IpcInvokeMethod<typeof Ipc.storage.delete>
storeClear: IpcInvokeMethod<typeof Ipc.storage.clear>
storeKeys: IpcInvokeMethod<typeof Ipc.storage.keys>
storeLength: IpcInvokeMethod<typeof Ipc.storage.length>
draftGet: IpcInvokeMethod<typeof Ipc.drafts.get>
draftSet: IpcInvokeMethod<typeof Ipc.drafts.set>
draftDelete: IpcInvokeMethod<typeof Ipc.drafts.delete>
draftBlobPut: IpcInvokeMethod<typeof Ipc.drafts.putBlob>
draftBlobGet: IpcInvokeMethod<typeof Ipc.drafts.getBlob>
getWindowID: IpcInvokeMethod<typeof Ipc.window.getId>
themeReady: IpcInvokeMethod<typeof Ipc.window.themeReady>
onMenuCommand: IpcEventSubscription<typeof Ipc.menu.command>
onDeepLink: IpcEventSubscription<typeof Ipc.app.deepLink>
openDirectoryPicker: IpcInvokeMethod<typeof Ipc.files.openDirectoryPicker>
openFilePicker: IpcInvokeMethod<typeof Ipc.files.openFilePicker>
readPickedFile: IpcInvokeMethod<typeof Ipc.files.readPickedFile>
releasePickedFiles: IpcInvokeMethod<typeof Ipc.files.releasePickedFiles>
getPathForFile: (file: File) => string
saveFilePicker: IpcInvokeMethod<typeof Ipc.files.saveFilePicker>
openExternal: IpcSendMethod<typeof Ipc.files.openExternal>
openLocalFile: IpcSendMethod<typeof Ipc.files.openLocalFile>
openPath: IpcInvokeMethod<typeof Ipc.files.openPath>
revealPath: IpcInvokeMethod<typeof Ipc.files.revealPath>
readClipboardImage: IpcInvokeMethod<typeof Ipc.files.readClipboardImage>
getWindowFocused: IpcInvokeMethod<typeof Ipc.window.getFocused>
getWindowFullscreen: IpcInvokeMethod<typeof Ipc.window.getFullscreen>
onWindowFullscreenChanged: IpcEventSubscription<typeof Ipc.window.fullscreenChanged>
setWindowFocus: IpcInvokeMethod<typeof Ipc.window.setFocus>
showWindow: IpcInvokeMethod<typeof Ipc.window.show>
relaunch: IpcSendMethod<typeof Ipc.app.relaunch>
getZoomFactor: IpcInvokeMethod<typeof Ipc.window.getZoomFactor>
setZoomFactor: IpcInvokeMethod<typeof Ipc.window.setZoomFactor>
getPinchZoomEnabled: IpcInvokeMethod<typeof Ipc.window.getPinchZoomEnabled>
setPinchZoomEnabled: IpcInvokeMethod<typeof Ipc.window.setPinchZoomEnabled>
onPinchZoomEnabledChanged: IpcEventSubscription<typeof Ipc.window.pinchZoomEnabledChanged>
onZoomFactorChanged: IpcEventSubscription<typeof Ipc.window.zoomFactorChanged>
setTitlebar: IpcInvokeMethod<typeof Ipc.window.setTitlebar>
runDesktopMenuAction: IpcInvokeMethod<typeof Ipc.menu.runAction>
setBackgroundColor: IpcInvokeMethod<typeof Ipc.app.setBackgroundColor>
exportDebugLogs: IpcInvokeMethod<typeof Ipc.app.exportDebugLogs>
setForceFocus: IpcInvokeMethod<typeof Ipc.app.setForceFocus>
recordFatalRendererError: IpcInvokeMethod<typeof Ipc.app.recordFatalRendererError>
setNativeTranslations: IpcInvokeMethod<typeof Ipc.app.setNativeTranslations>
export type ElectronNative = {
getPathForFile(file: File): string
}
@@ -0,0 +1,79 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode-ai/app/updater"
import type { WslServersPlatform } from "@opencode-ai/app/wsl/types"
import type {
ClipboardImage,
DirectoryPickerOptions,
FatalRendererError,
FilePickerOptions,
PickedFiles,
SaveFilePickerOptions,
ServerReadyData,
TitlebarTheme,
} from "../shared/ipc-contract"
export type WslServersAPI = WslServersPlatform
export type UpdaterAPI = {
subscribe(cb: (state: UpdaterState) => void): Promise<() => void>
check(): Promise<UpdaterState>
install(): Promise<void>
}
export type ElectronAPI = {
awaitInitialization(): Promise<ServerReadyData>
wslServers: WslServersAPI
updater: UpdaterAPI
consumeInitialDeepLinks(): Promise<string[]>
getDefaultServerUrl(): Promise<string | null>
setDefaultServerUrl(url: string | null): Promise<void>
isFirstLaunchOnboardingPending(): Promise<boolean>
finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise<string | null>
checkAppExists(appName: string): Promise<boolean>
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>
draftGet(key: string): Promise<string | null>
draftSet(key: string, value: string): Promise<void>
draftDelete(key: string): Promise<void>
draftBlobPut(data: ArrayBuffer): Promise<string>
draftBlobGet(id: string): Promise<ArrayBuffer | null>
getWindowID(): Promise<string>
themeReady(): Promise<void>
onMenuCommand(cb: (id: string) => void): () => void
onDeepLink(cb: (urls: string[]) => void): () => void
openDirectoryPicker(opts?: DirectoryPickerOptions): Promise<string | string[] | null>
openFilePicker(opts?: FilePickerOptions): Promise<PickedFiles | null>
readPickedFile(token: string, path: string): Promise<ArrayBuffer>
releasePickedFiles(token: string): Promise<void>
getPathForFile(file: File): string
saveFilePicker(opts?: SaveFilePickerOptions): Promise<string | null>
openExternal(url: string): void
openLocalFile(url: string): void
openPath(path: string, app?: string): Promise<string | undefined>
revealPath(path: string): Promise<boolean>
readClipboardImage(): Promise<ClipboardImage | null>
getWindowFocused(): Promise<boolean>
getWindowFullscreen(): Promise<boolean>
onWindowFullscreenChanged(cb: (fullscreen: boolean) => void): () => void
setWindowFocus(): Promise<void>
showWindow(): Promise<void>
relaunch(): void
getZoomFactor(): Promise<number>
setZoomFactor(factor: number): Promise<void>
getPinchZoomEnabled(): Promise<boolean>
setPinchZoomEnabled(enabled: boolean): Promise<void>
onPinchZoomEnabledChanged(cb: (enabled: boolean) => void): () => void
onZoomFactorChanged(cb: (factor: number) => void): () => void
setTitlebar(theme: TitlebarTheme): Promise<void>
runDesktopMenuAction(action: DesktopMenuAction): Promise<void>
setBackgroundColor(color: string): Promise<void>
exportDebugLogs(): Promise<string>
setForceFocus(enabled: boolean): Promise<void>
recordFatalRendererError(error: FatalRendererError): Promise<void>
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
}
+127
View File
@@ -0,0 +1,127 @@
import type { ElectronAPI } from "./api-types"
import type { UpdaterState } from "@opencode-ai/app/updater"
import { invoke, listen, send } from "./ipc-client"
type Mutable<Value> =
Value extends ReadonlyArray<unknown>
? { -readonly [Key in keyof Value]: Mutable<Value[Key]> }
: Value extends object
? { -readonly [Key in keyof Value]: Mutable<Value[Key]> }
: Value
const mutable = <Value>(value: Value) => value as Mutable<Value>
const toArrayBuffer = (value: Uint8Array) =>
value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength) as ArrayBuffer
const updaterCallbacks = new Set<(state: UpdaterState) => void>()
let updaterState: UpdaterState | undefined
let updaterSubscription: Promise<void> | undefined
let updaterListener: (() => void) | undefined
const updaterHandler = (state: UpdaterState) => {
updaterState = state
updaterCallbacks.forEach((callback) => callback(state))
}
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
wslServers: {
getState: () => invoke("WslGetState").then(mutable),
subscribe: (cb) => {
const dispose = listen("WslServersChanged", (event) => cb(mutable(event.event)))
void invoke("WslSubscribe")
return () => {
dispose()
void invoke("WslUnsubscribe")
}
},
probeRuntime: () => invoke("WslProbeRuntime"),
refreshDistros: () => invoke("WslRefreshDistros"),
installWsl: () => invoke("WslInstallWsl"),
installDistro: (name) => invoke("WslInstallDistro", { name }),
probeAddable: (distros) => invoke("WslProbeAddable", { distros }),
installOpencode: (name) => invoke("WslInstallOpencode", { name }),
openTerminal: (name) => invoke("WslOpenTerminal", { name }),
addServer: (distro) => invoke("WslAddServer", { distro }),
removeServer: (id) => invoke("WslRemoveServer", { id }),
startServer: (id) => invoke("WslStartServer", { id }),
},
updater: {
subscribe: async (cb) => {
updaterCallbacks.add(cb)
if (updaterState) cb(updaterState)
if (!updaterSubscription) {
updaterListener = listen("UpdaterStateChanged", (event) => updaterHandler(mutable(event.state)))
updaterSubscription = invoke("UpdaterSubscribe")
}
await updaterSubscription
return () => {
updaterCallbacks.delete(cb)
if (updaterCallbacks.size > 0) return
updaterListener?.()
updaterListener = undefined
updaterSubscription = undefined
void invoke("UpdaterUnsubscribe")
}
},
check: () => invoke("UpdaterCheck"),
install: () => invoke("UpdaterInstall"),
},
consumeInitialDeepLinks: () => invoke("AppConsumeInitialDeepLinks").then(mutable),
getDefaultServerUrl: () => invoke("AppGetDefaultServerUrl"),
setDefaultServerUrl: (url) => invoke("AppSetDefaultServerUrl", { url }),
isFirstLaunchOnboardingPending: () => invoke("AppIsFirstLaunchOnboardingPending"),
finishFirstLaunchOnboarding: (createDefaultProject) =>
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
storeGet: (name, key) => invoke("StorageGet", { name, key }),
storeSet: (name, key, value) => invoke("StorageSet", { name, key, value }),
storeDelete: (name, key) => invoke("StorageDelete", { name, key }),
storeClear: (name) => invoke("StorageClear", { name }),
storeKeys: (name) => invoke("StorageKeys", { name }).then(mutable),
storeLength: (name) => invoke("StorageLength", { name }),
draftGet: (key) => invoke("DraftsGet", { key }),
draftSet: (key, value) => invoke("DraftsSet", { key, value }),
draftDelete: (key) => invoke("DraftsDelete", { key }),
draftBlobPut: (data) => invoke("DraftsPutBlob", { data: new Uint8Array(data) }),
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
getWindowID: () => invoke("WindowGetId"),
themeReady: () => invoke("WindowThemeReady"),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
openDirectoryPicker: (opts) => invoke("FilesOpenDirectoryPicker", { options: opts }).then(mutable),
openFilePicker: (opts) => invoke("FilesOpenFilePicker", { options: opts }).then(mutable),
readPickedFile: (token, path) => invoke("FilesReadPickedFile", { token, path }).then(toArrayBuffer),
releasePickedFiles: (token) => invoke("FilesReleasePickedFiles", { token }),
getPathForFile: (file) => window.electron.getPathForFile(file),
saveFilePicker: (opts) => invoke("FilesSaveFilePicker", { options: opts }),
openExternal: (url) => send("FilesOpenExternal", { url }),
openLocalFile: (url) => send("FilesOpenLocalFile", { url }),
openPath: (path, app) => invoke("FilesOpenPath", { path, application: app }).then((value) => value ?? undefined),
revealPath: (path) => invoke("FilesRevealPath", { path }),
readClipboardImage: () =>
invoke("FilesReadClipboardImage").then((image) =>
image ? { ...image, buffer: toArrayBuffer(image.buffer) } : null,
),
getWindowFocused: () => invoke("WindowGetFocused"),
getWindowFullscreen: () => invoke("WindowGetFullscreen"),
onWindowFullscreenChanged: (cb) => listen("WindowFullscreenChanged", (event) => cb(event.fullscreen)),
setWindowFocus: () => invoke("WindowSetFocus"),
showWindow: () => invoke("WindowShow"),
relaunch: () => send("AppRelaunch"),
getZoomFactor: () => invoke("WindowGetZoomFactor"),
setZoomFactor: (factor) => invoke("WindowSetZoomFactor", { factor }),
getPinchZoomEnabled: () => invoke("WindowGetPinchZoomEnabled"),
setPinchZoomEnabled: (enabled) => invoke("WindowSetPinchZoomEnabled", { enabled }),
onPinchZoomEnabledChanged: (cb) => listen("WindowPinchZoomChanged", (event) => cb(event.enabled)),
onZoomFactorChanged: (cb) => listen("WindowZoomChanged", (event) => cb(event.factor)),
setTitlebar: (theme) => invoke("WindowSetTitlebar", { theme }),
runDesktopMenuAction: (action) => invoke("MenuRunAction", { action }),
setBackgroundColor: (color) => invoke("AppSetBackgroundColor", { color }),
exportDebugLogs: () => invoke("AppExportDebugLogs"),
setForceFocus: (enabled) => invoke("AppSetForceFocus", { enabled }),
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
}
@@ -5,7 +5,6 @@ import {
AppBaseProviders,
AppInterface,
PlatformProvider,
preloadRoute,
ServerConnection,
useCommand,
useLanguage,
@@ -15,7 +14,7 @@ import {
import { useTheme } from "@opencode-ai/ui/theme/context"
import type { BaseRouterProps } from "@solidjs/router"
import { createEffect, createMemo, createResource, lazy, Show, Suspense } from "solid-js"
import type { ElectronAPI } from "../preload/types"
import type { ElectronAPI } from "./api-types"
import { DesktopFirstLaunchOnboarding } from "./onboarding"
import { createDesktopPlatform, type DesktopWindowState } from "./platform"
import { bindDesktopMenu } from "./platform/menu"
@@ -39,11 +38,9 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; windowState: DesktopWindowState }) {
const platform = createDesktopPlatform(props.api, props.windowState, props.updater)
const initialUrl = getLastActiveUrl(props.windowState.id)
const [sidecar] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(() => preloadStoredLocale(platform))
const [route] = createResource(() => preloadRoute(initialUrl))
const router = (routerProps: BaseRouterProps) => (
<DesktopMemoryRouter {...routerProps} windowID={props.windowState.id} />
)
@@ -51,7 +48,9 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
function ReadyApp() {
const wslServers = useWslServers()
const language = useLanguage()
const ready = createMemo(() => !defaultServer.loading && !sidecar.loading && !locale.loading && !route.loading)
const ready = createMemo(
() => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading,
)
const servers = createMemo(() => {
const data = initializationData(sidecar)
const list: ServerConnection.Any[] = []
@@ -81,7 +80,7 @@ function DesktopWindow(props: { api: ElectronAPI; updater: UpdaterPlatform; wind
<AppInterface defaultServer={key} servers={servers()} router={router}>
<DesktopFirstLaunchOnboarding
api={props.api}
initialUrl={initialUrl}
initialUrl={getLastActiveUrl(props.windowState.id)}
serverKey={key}
/>
<DesktopEffects api={props.api} />
+2 -2
View File
@@ -1,8 +1,8 @@
import type { ElectronAPI } from "../preload/types"
import type { ElectronNative } from "../preload/types"
declare global {
interface Window {
api: ElectronAPI
electron: ElectronNative
__OPENCODE__?: {
deepLinks?: string[]
}
+5 -4
View File
@@ -3,6 +3,7 @@
import "./diagnostics"
import "./styles.css"
import { render } from "solid-js/web"
import { api } from "./api"
import { DesktopApp } from "./desktop-app"
import { startDesktopMenu } from "./platform/menu"
import { startDesktopUpdater } from "./platform/updater"
@@ -14,8 +15,8 @@ const root = requireRendererRoot()
const version = desktopVersion()
await initializeSentry(version)
const updater = startDesktopUpdater(window.api)
startDesktopMenu(window.api)
startDeepLinks(window.api)
const updater = startDesktopUpdater(api)
startDesktopMenu(api)
startDeepLinks(api)
render(() => <DesktopApp api={window.api} updater={updater} version={version} />, root)
render(() => <DesktopApp api={api} updater={updater} version={version} />, root)
+114
View File
@@ -0,0 +1,114 @@
import { Context, Effect, Layer, ManagedRuntime, Queue, Stream } from "effect"
import { RpcClient, RpcMessage, RpcSerialization } from "effect/unstable/rpc"
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
import { IpcTransportPort } from "../shared/ipc-transport"
class DesktopClient extends Context.Service<DesktopClient, DesktopRpcClient>()("opencode/desktop/DesktopClient") {}
type EventTag = DesktopEvent["_tag"]
type InvokeTag = Exclude<keyof DesktopRpcClient, "DesktopEvents">
type InvokeArgs<Tag extends InvokeTag> = Parameters<DesktopRpcClient[Tag]>
type InvokeResult<Tag extends InvokeTag> =
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
const port = new Promise<MessagePort>((resolve) => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window || event.data !== IpcTransportPort) return
const value = event.ports[0]
if (!value) return
window.removeEventListener("message", onMessage)
resolve(value)
}
window.addEventListener("message", onMessage)
})
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
const ClientLive = Layer.effect(DesktopClient, RpcClient.make(DesktopRpcs)).pipe(Layer.provide(ClientProtocolLive))
const runtime = ManagedRuntime.make(ClientLive)
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
window.addEventListener("pagehide", () => void runtime.dispose(), { once: true })
runtime.runFork(
Effect.gen(function* () {
const client = yield* DesktopClient
yield* client
.DesktopEvents()
.pipe(
Stream.runForEach((event) =>
Effect.sync(() => listeners.get(event._tag)?.forEach((listener) => listener(event))),
),
)
}),
)
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* DesktopClient
const method = client[tag] as unknown as (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, unknown>
return yield* method(...payload)
}),
) as Promise<InvokeResult<Tag>>
}
export function send<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>) {
void invoke(tag, ...payload).catch(() => undefined)
}
export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventValue<Tag>) => void) {
const callback = listener as (value: unknown) => void
const callbacks = listeners.get(tag) ?? new Set()
callbacks.add(callback)
listeners.set(tag, callbacks)
return () => {
callbacks.delete(callback)
if (callbacks.size === 0) listeners.delete(tag)
}
}
function clientProtocol(value: MessagePort) {
return Layer.effect(
RpcClient.Protocol,
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const serialization = yield* RpcSerialization.RpcSerialization
const parser = serialization.makeUnsafe()
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) => {
try {
parser
.decode(event.data)
.forEach((message) => Queue.offerUnsafe(inbound, message as RpcMessage.FromServerEncoded))
} catch {
return
}
}
value.addEventListener("message", onMessage)
value.start()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
value.removeEventListener("message", onMessage)
value.close()
}),
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach((message) =>
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
),
Effect.forkScoped,
)
return {
send: (_clientId, request) =>
Effect.sync(() => {
const encoded = parser.encode(request)
if (encoded !== undefined) value.postMessage(encoded)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
).pipe(Layer.provide(RpcSerialization.layerMsgPack))
}
@@ -1,6 +1,6 @@
import { ServerConnection, useServers, useTabs } from "@opencode-ai/app/desktop"
import { onMount } from "solid-js"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function DesktopFirstLaunchOnboarding(props: {
api: ElectronAPI
@@ -1,5 +1,5 @@
import type { Platform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
type DesktopOS = Extract<Platform, { platform: "desktop" }>["os"]
type DesktopFileAPI = Pick<
@@ -1,5 +1,5 @@
import { ACCEPTED_FILE_EXTENSIONS, ServerConnection, type Platform, type UpdaterPlatform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
import { setPinchZoomEnabled, webviewZoom } from "../window/zoom"
import { windowFullscreen } from "../window/fullscreen"
import { createDesktopFiles } from "./files"
@@ -1,5 +1,5 @@
import type { Platform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
import { resetZoom, zoomIn, zoomOut } from "../window/zoom"
let trigger: ((id: string) => void) | null = null
@@ -1,5 +1,5 @@
import type { Platform } from "@opencode-ai/app"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function createDesktopNotify(api: ElectronAPI): Platform["notify"] {
return async (title, description, onClick) => {
@@ -1,6 +1,6 @@
import { createDraftStore, type Platform } from "@opencode-ai/app"
import type { AsyncStorage } from "@solid-primitives/storage"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function createDesktopStorage(api: ElectronAPI) {
const cache = new Map<string, AsyncStorage>()
@@ -1,6 +1,6 @@
import type { UpdaterPlatform, UpdaterState } from "@opencode-ai/app"
import { createSignal } from "solid-js"
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
export function startDesktopUpdater(api: ElectronAPI): UpdaterPlatform {
const [state, setState] = createSignal<UpdaterState>({ status: "disabled" })
@@ -1,4 +1,4 @@
import type { ElectronAPI } from "../../preload/types"
import type { ElectronAPI } from "../api-types"
const deepLinkEvent = "opencode:deep-link"
@@ -1,5 +1,4 @@
import { describe, expect, test } from "bun:test"
import { Ipc } from "../../shared/ipc-contract"
import { initializationData, initializationReady } from "./initialization"
describe("desktop renderer initialization", () => {
@@ -15,10 +14,8 @@ describe("desktop renderer initialization", () => {
}
})
test("removes Electron's remote invocation wrapper from startup errors", () => {
const error = new Error(
`Error invoking remote method '${Ipc.app.awaitInitialization}': Error: Cannot migrate session_message projections`,
)
test("preserves clean RPC startup errors", () => {
const error = new Error("Cannot migrate session_message projections")
try {
initializationData(Object.assign(() => undefined, { error }))
@@ -5,12 +5,6 @@ export function initializationData<A>(state: (() => A | undefined) & { error: un
function markLocalServerStartup(error: unknown) {
const failure = error instanceof Error ? error : new Error(String(error))
const prefix = `Error invoking remote method '${Ipc.app.awaitInitialization}': Error: `
if (failure.message.startsWith(prefix)) {
const previous = failure.message
failure.message = failure.message.slice(prefix.length)
if (failure.stack) failure.stack = failure.stack.replace(`Error: ${previous}`, `Error: ${failure.message}`)
}
Object.defineProperty(failure, "localServerStartup", { value: true })
return failure
}
@@ -20,4 +14,3 @@ export function initializationReady<A>(state: (() => A | undefined) & { error: u
initializationData(state)
return true
}
import { Ipc } from "../../shared/ipc-contract"
@@ -1,8 +1,9 @@
import { createSignal } from "solid-js"
import { api } from "../api"
const [windowFullscreen, setWindowFullscreen] = createSignal(false)
window.api.onWindowFullscreenChanged(setWindowFullscreen)
void window.api.getWindowFullscreen().then(setWindowFullscreen)
api.onWindowFullscreenChanged(setWindowFullscreen)
void api.getWindowFullscreen().then(setWindowFullscreen)
export { windowFullscreen }
+6 -5
View File
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT
import { createSignal } from "solid-js"
import { api } from "../api"
const OS_NAME = (() => {
if (navigator.userAgent.includes("Mac")) return "macos"
@@ -33,7 +34,7 @@ const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_Z
const applyZoom = (next: number) => {
requestedZoom = next
void window.api
void api
.setZoomFactor(next)
.then(() => {
if (requestedZoom !== next) return
@@ -45,16 +46,16 @@ const applyZoom = (next: number) => {
})
}
window.api.onZoomFactorChanged((factor) => {
api.onZoomFactorChanged((factor) => {
requestedZoom = clamp(factor)
setWebviewZoom(requestedZoom)
})
void window.api.getPinchZoomEnabled().then((enabled) => {
void api.getPinchZoomEnabled().then((enabled) => {
pinchZoomEnabled = enabled
})
window.api.onPinchZoomEnabledChanged((enabled) => {
api.onPinchZoomEnabledChanged((enabled) => {
pinchZoomEnabled = enabled
resetWheelPinch()
})
@@ -62,7 +63,7 @@ window.api.onPinchZoomEnabledChanged((enabled) => {
const setPinchZoomEnabled = (enabled: boolean) => {
pinchZoomEnabled = enabled
resetWheelPinch()
return window.api.setPinchZoomEnabled(enabled)
return api.setPinchZoomEnabled(enabled)
}
const resetZoom = () => applyZoom(1)
-207
View File
@@ -1,99 +1,3 @@
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native"
import type { UpdaterState } from "@opencode-ai/app/updater"
import type { WslServerConfig, WslServersEvent, WslServersState } from "@opencode-ai/app/wsl/types"
export const Ipc = {
app: {
awaitInitialization: "await-initialization",
consumeInitialDeepLinks: "consume-initial-deep-links",
deepLink: "deep-link",
getDefaultServerUrl: "get-default-server-url",
setDefaultServerUrl: "set-default-server-url",
isFirstLaunchOnboardingPending: "is-first-launch-onboarding-pending",
finishFirstLaunchOnboarding: "finish-first-launch-onboarding",
checkAppExists: "check-app-exists",
resolveAppPath: "resolve-app-path",
relaunch: "relaunch",
setBackgroundColor: "set-background-color",
exportDebugLogs: "export-debug-logs",
setForceFocus: "set-force-focus",
recordFatalRendererError: "record-fatal-renderer-error",
setNativeTranslations: "set-native-translations",
},
storage: {
get: "store-get",
set: "store-set",
delete: "store-delete",
clear: "store-clear",
keys: "store-keys",
length: "store-length",
},
drafts: {
get: "draft-get",
set: "draft-set",
delete: "draft-delete",
putBlob: "draft-blob-put",
getBlob: "draft-blob-get",
},
files: {
openDirectoryPicker: "open-directory-picker",
openFilePicker: "open-file-picker",
readPickedFile: "read-picked-file",
releasePickedFiles: "release-picked-files",
saveFilePicker: "save-file-picker",
openExternal: "open-external",
openLocalFile: "open-local-file",
openPath: "open-path",
revealPath: "reveal-path",
readClipboardImage: "read-clipboard-image",
},
window: {
getId: "get-window-id",
themeReady: "window-theme-ready",
getFocused: "get-window-focused",
getFullscreen: "get-window-fullscreen",
fullscreenChanged: "window-fullscreen-changed",
setFocus: "set-window-focus",
show: "show-window",
getZoomFactor: "get-zoom-factor",
setZoomFactor: "set-zoom-factor",
zoomFactorChanged: "zoom-factor-changed",
getPinchZoomEnabled: "get-pinch-zoom-enabled",
setPinchZoomEnabled: "set-pinch-zoom-enabled",
pinchZoomEnabledChanged: "pinch-zoom-enabled-changed",
setTitlebar: "set-titlebar",
},
menu: {
command: "menu-command",
runAction: "run-desktop-menu-action",
},
updater: {
subscribe: "updater-subscribe",
unsubscribe: "updater-unsubscribe",
check: "updater-check",
install: "updater-install",
state: "updater-state",
},
wsl: {
awaitInitialization: "wsl-servers-await-initialization",
subscribe: "wsl-servers-subscribe",
unsubscribe: "wsl-servers-unsubscribe",
getState: "wsl-servers-get-state",
probeRuntime: "wsl-servers-probe-runtime",
refreshDistros: "wsl-servers-refresh-distros",
installWsl: "wsl-servers-install-wsl",
installDistro: "wsl-servers-install-distro",
probeAddable: "wsl-servers-probe-addable",
installOpencode: "wsl-servers-install-opencode",
openTerminal: "wsl-servers-open-terminal",
addServer: "wsl-servers-add",
removeServer: "wsl-servers-remove",
startServer: "wsl-servers-start",
event: "wsl-servers-event",
},
} as const
export type ServerReadyData = {
url: string
username: string | null
@@ -138,114 +42,3 @@ export type ClipboardImage = {
width: number
height: number
}
export type IpcInvoke = {
[Ipc.app.awaitInitialization]: { args: []; result: ServerReadyData }
[Ipc.app.consumeInitialDeepLinks]: { args: []; result: string[] }
[Ipc.app.getDefaultServerUrl]: { args: []; result: string | null }
[Ipc.app.setDefaultServerUrl]: { args: [url: string | null]; result: void }
[Ipc.app.isFirstLaunchOnboardingPending]: { args: []; result: boolean }
[Ipc.app.finishFirstLaunchOnboarding]: { args: [createDefaultProject: boolean]; result: string | null }
[Ipc.app.checkAppExists]: { args: [appName: string]; result: boolean }
[Ipc.app.resolveAppPath]: { args: [appName: string]; result: string | null }
[Ipc.app.setBackgroundColor]: { args: [color: string]; result: void }
[Ipc.app.exportDebugLogs]: { args: []; result: string }
[Ipc.app.setForceFocus]: { args: [enabled: boolean]; result: void }
[Ipc.app.recordFatalRendererError]: { args: [error: FatalRendererError]; result: void }
[Ipc.app.setNativeTranslations]: { args: [bundle: DesktopNativeBundle]; result: void }
[Ipc.storage.get]: { args: [name: string, key: string]; result: string | null }
[Ipc.storage.set]: { args: [name: string, key: string, value: string]; result: void }
[Ipc.storage.delete]: { args: [name: string, key: string]; result: void }
[Ipc.storage.clear]: { args: [name: string]; result: void }
[Ipc.storage.keys]: { args: [name: string]; result: string[] }
[Ipc.storage.length]: { args: [name: string]; result: number }
[Ipc.drafts.get]: { args: [key: string]; result: string | null }
[Ipc.drafts.set]: { args: [key: string, value: string]; result: void }
[Ipc.drafts.delete]: { args: [key: string]; result: void }
[Ipc.drafts.putBlob]: { args: [data: ArrayBuffer]; result: string }
[Ipc.drafts.getBlob]: { args: [id: string]; result: ArrayBuffer | null }
[Ipc.files.openDirectoryPicker]: {
args: [options?: DirectoryPickerOptions]
result: string | string[] | null
}
[Ipc.files.openFilePicker]: { args: [options?: FilePickerOptions]; result: PickedFiles | null }
[Ipc.files.readPickedFile]: { args: [token: string, path: string]; result: ArrayBuffer }
[Ipc.files.releasePickedFiles]: { args: [token: string]; result: void }
[Ipc.files.saveFilePicker]: { args: [options?: SaveFilePickerOptions]; result: string | null }
[Ipc.files.openPath]: { args: [path: string, app?: string]; result: string | undefined }
[Ipc.files.revealPath]: { args: [path: string]; result: boolean }
[Ipc.files.readClipboardImage]: { args: []; result: ClipboardImage | null }
[Ipc.window.getId]: { args: []; result: string }
[Ipc.window.themeReady]: { args: []; result: void }
[Ipc.window.getFocused]: { args: []; result: boolean }
[Ipc.window.getFullscreen]: { args: []; result: boolean }
[Ipc.window.setFocus]: { args: []; result: void }
[Ipc.window.show]: { args: []; result: void }
[Ipc.window.getZoomFactor]: { args: []; result: number }
[Ipc.window.setZoomFactor]: { args: [factor: number]; result: void }
[Ipc.window.getPinchZoomEnabled]: { args: []; result: boolean }
[Ipc.window.setPinchZoomEnabled]: { args: [enabled: boolean]; result: void }
[Ipc.window.setTitlebar]: { args: [theme: TitlebarTheme]; result: void }
[Ipc.menu.runAction]: { args: [action: DesktopMenuAction]; result: void }
[Ipc.updater.subscribe]: { args: []; result: void }
[Ipc.updater.unsubscribe]: { args: []; result: void }
[Ipc.updater.check]: { args: []; result: UpdaterState }
[Ipc.updater.install]: { args: []; result: void }
[Ipc.wsl.awaitInitialization]: { args: []; result: void }
[Ipc.wsl.subscribe]: { args: []; result: void }
[Ipc.wsl.unsubscribe]: { args: []; result: void }
[Ipc.wsl.getState]: { args: []; result: WslServersState }
[Ipc.wsl.probeRuntime]: { args: []; result: void }
[Ipc.wsl.refreshDistros]: { args: []; result: void }
[Ipc.wsl.installWsl]: { args: []; result: void }
[Ipc.wsl.installDistro]: { args: [name: string]; result: void }
[Ipc.wsl.probeAddable]: { args: [distros: string[]]; result: void }
[Ipc.wsl.installOpencode]: { args: [name: string]; result: void }
[Ipc.wsl.openTerminal]: { args: [name: string]; result: void }
[Ipc.wsl.addServer]: { args: [distro: string]; result: WslServerConfig }
[Ipc.wsl.removeServer]: { args: [id: string]; result: void }
[Ipc.wsl.startServer]: { args: [id: string]; result: void }
}
export type IpcSend = {
[Ipc.app.relaunch]: []
[Ipc.files.openExternal]: [url: string]
[Ipc.files.openLocalFile]: [url: string]
}
export type IpcEvent = {
[Ipc.app.deepLink]: [urls: string[]]
[Ipc.menu.command]: [id: string]
[Ipc.updater.state]: [state: UpdaterState]
[Ipc.wsl.event]: [event: WslServersEvent]
[Ipc.window.fullscreenChanged]: [fullscreen: boolean]
[Ipc.window.pinchZoomEnabledChanged]: [enabled: boolean]
[Ipc.window.zoomFactorChanged]: [factor: number]
}
export type IpcInvokeArgs<Channel extends keyof IpcInvoke> = IpcInvoke[Channel]["args"]
export type IpcInvokeResult<Channel extends keyof IpcInvoke> = IpcInvoke[Channel]["result"]
export type IpcInvokeMethod<Channel extends keyof IpcInvoke> = (
...args: IpcInvokeArgs<Channel>
) => Promise<IpcInvokeResult<Channel>>
export type IpcSendMethod<Channel extends keyof IpcSend> = (...args: IpcSend[Channel]) => void
export type IpcEventListener<Channel extends keyof IpcEvent> = (...args: IpcEvent[Channel]) => void
export type IpcEventSubscription<Channel extends keyof IpcEvent> = (listener: IpcEventListener<Channel>) => () => void
type IpcEventSender = {
send<Channel extends keyof IpcEvent>(channel: Channel, ...args: IpcEvent[Channel]): void
}
export function sendIpcEvent<Channel extends keyof IpcEvent>(
sender: IpcEventSender,
channel: Channel,
...args: IpcEvent[Channel]
) {
sender.send(channel, ...args)
}
+21
View File
@@ -0,0 +1,21 @@
import { RpcClient, RpcClientError } from "effect/unstable/rpc"
import { AppRpcs } from "./ipc-rpc/app"
import { EventRpcs } from "./ipc-rpc/events"
import { FileRpcs } from "./ipc-rpc/files"
import { MenuRpcs } from "./ipc-rpc/menu"
import { StorageRpcs } from "./ipc-rpc/storage"
import { UpdaterRpcs } from "./ipc-rpc/updater"
import { WindowRpcs } from "./ipc-rpc/window"
import { WslRpcs } from "./ipc-rpc/wsl"
export { AppRpcs } from "./ipc-rpc/app"
export { EventRpcs } from "./ipc-rpc/events"
export { FileRpcs } from "./ipc-rpc/files"
export { MenuRpcs } from "./ipc-rpc/menu"
export { StorageRpcs } from "./ipc-rpc/storage"
export { UpdaterRpcs } from "./ipc-rpc/updater"
export { WindowRpcs } from "./ipc-rpc/window"
export { WslRpcs } from "./ipc-rpc/wsl"
export const DesktopRpcs = AppRpcs.merge(StorageRpcs, FileRpcs, WindowRpcs, MenuRpcs, UpdaterRpcs, WslRpcs, EventRpcs)
export type DesktopRpcClient = RpcClient.FromGroup<typeof DesktopRpcs, RpcClientError.RpcClientError>
@@ -0,0 +1,72 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const ServerReadyData = Schema.Struct({
url: Schema.String,
username: Schema.NullOr(Schema.String),
password: Schema.NullOr(Schema.String),
})
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
export const AppConsumeInitialDeepLinks = Rpc.make("AppConsumeInitialDeepLinks", {
success: Schema.Array(Schema.String),
})
export const AppGetDefaultServerUrl = Rpc.make("AppGetDefaultServerUrl", {
success: Schema.NullOr(Schema.String),
})
export const AppSetDefaultServerUrl = Rpc.make("AppSetDefaultServerUrl", {
payload: { url: Schema.NullOr(Schema.String) },
})
export const AppIsFirstLaunchOnboardingPending = Rpc.make("AppIsFirstLaunchOnboardingPending", {
success: Schema.Boolean,
})
export const AppFinishFirstLaunchOnboarding = Rpc.make("AppFinishFirstLaunchOnboarding", {
payload: { createDefaultProject: Schema.Boolean },
success: Schema.NullOr(Schema.String),
})
export const AppCheckAppExists = Rpc.make("AppCheckAppExists", {
payload: { appName: Schema.String },
success: Schema.Boolean,
})
export const AppResolveAppPath = Rpc.make("AppResolveAppPath", {
payload: { appName: Schema.String },
success: Schema.NullOr(Schema.String),
})
export const AppSetBackgroundColor = Rpc.make("AppSetBackgroundColor", {
payload: { color: Schema.String },
})
export const AppExportDebugLogs = Rpc.make("AppExportDebugLogs", { success: Schema.String })
export const AppSetForceFocus = Rpc.make("AppSetForceFocus", {
payload: { enabled: Schema.Boolean },
})
export const AppRecordFatalRendererError = Rpc.make("AppRecordFatalRendererError", {
payload: {
error: Schema.Struct({
error: Schema.String,
url: Schema.String,
version: Schema.optionalKey(Schema.String),
platform: Schema.String,
os: Schema.optionalKey(Schema.String),
}),
},
})
export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
payload: { value: Schema.Unknown },
})
export const AppRelaunch = Rpc.make("AppRelaunch")
export const AppRpcs = RpcGroup.make(
AppAwaitInitialization,
AppConsumeInitialDeepLinks,
AppGetDefaultServerUrl,
AppSetDefaultServerUrl,
AppIsFirstLaunchOnboardingPending,
AppFinishFirstLaunchOnboarding,
AppCheckAppExists,
AppResolveAppPath,
AppSetBackgroundColor,
AppExportDebugLogs,
AppSetForceFocus,
AppRecordFatalRendererError,
AppSetNativeTranslations,
AppRelaunch,
)
@@ -0,0 +1,46 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
import { UpdaterStateSchema } from "./updater"
import { WslServersEventSchema } from "./wsl"
export class DeepLinksOpened extends Schema.TaggedClass<DeepLinksOpened>()("DeepLinksOpened", {
urls: Schema.Array(Schema.String),
}) {}
export class MenuCommandTriggered extends Schema.TaggedClass<MenuCommandTriggered>()("MenuCommandTriggered", {
id: Schema.String,
}) {}
export class UpdaterStateChanged extends Schema.TaggedClass<UpdaterStateChanged>()("UpdaterStateChanged", {
state: UpdaterStateSchema,
}) {}
export class WslServersChanged extends Schema.TaggedClass<WslServersChanged>()("WslServersChanged", {
event: WslServersEventSchema,
}) {}
export class WindowFullscreenChanged extends Schema.TaggedClass<WindowFullscreenChanged>()("WindowFullscreenChanged", {
fullscreen: Schema.Boolean,
}) {}
export class WindowPinchZoomChanged extends Schema.TaggedClass<WindowPinchZoomChanged>()("WindowPinchZoomChanged", {
enabled: Schema.Boolean,
}) {}
export class WindowZoomChanged extends Schema.TaggedClass<WindowZoomChanged>()("WindowZoomChanged", {
factor: Schema.Number,
}) {}
export const DesktopEvent = Schema.Union([
DeepLinksOpened,
MenuCommandTriggered,
UpdaterStateChanged,
WslServersChanged,
WindowFullscreenChanged,
WindowPinchZoomChanged,
WindowZoomChanged,
])
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
export const EventRpcs = RpcGroup.make(DesktopEvents)
@@ -0,0 +1,71 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const OptionalString = Schema.optionalKey(Schema.String)
const PickerOptions = Schema.Struct({
multiple: Schema.optionalKey(Schema.Boolean),
title: OptionalString,
defaultPath: OptionalString,
})
const FilePickerOptions = Schema.Struct({
multiple: Schema.optionalKey(Schema.Boolean),
title: OptionalString,
defaultPath: OptionalString,
extensions: Schema.optionalKey(Schema.Array(Schema.String)),
})
const SavePickerOptions = Schema.Struct({ title: OptionalString, defaultPath: OptionalString })
const PickedFiles = Schema.Struct({
token: Schema.String,
files: Schema.Array(Schema.Struct({ path: Schema.String, name: Schema.String, size: Schema.Number })),
})
const ClipboardImage = Schema.Struct({ buffer: Schema.Uint8Array, width: Schema.Number, height: Schema.Number })
export const FilesOpenDirectoryPicker = Rpc.make("FilesOpenDirectoryPicker", {
payload: { options: Schema.optionalKey(PickerOptions) },
success: Schema.NullOr(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
})
export const FilesOpenFilePicker = Rpc.make("FilesOpenFilePicker", {
payload: { options: Schema.optionalKey(FilePickerOptions) },
success: Schema.NullOr(PickedFiles),
})
export const FilesReadPickedFile = Rpc.make("FilesReadPickedFile", {
payload: { token: Schema.String, path: Schema.String },
success: Schema.Uint8Array,
})
export const FilesReleasePickedFiles = Rpc.make("FilesReleasePickedFiles", {
payload: { token: Schema.String },
})
export const FilesSaveFilePicker = Rpc.make("FilesSaveFilePicker", {
payload: { options: Schema.optionalKey(SavePickerOptions) },
success: Schema.NullOr(Schema.String),
})
export const FilesOpenExternal = Rpc.make("FilesOpenExternal", {
payload: { url: Schema.String },
})
export const FilesOpenLocalFile = Rpc.make("FilesOpenLocalFile", {
payload: { url: Schema.String },
})
export const FilesOpenPath = Rpc.make("FilesOpenPath", {
payload: { path: Schema.String, application: Schema.optionalKey(Schema.String) },
success: Schema.NullOr(Schema.String),
})
export const FilesRevealPath = Rpc.make("FilesRevealPath", {
payload: { path: Schema.String },
success: Schema.Boolean,
})
export const FilesReadClipboardImage = Rpc.make("FilesReadClipboardImage", {
success: Schema.NullOr(ClipboardImage),
})
export const FileRpcs = RpcGroup.make(
FilesOpenDirectoryPicker,
FilesOpenFilePicker,
FilesReadPickedFile,
FilesReleasePickedFiles,
FilesSaveFilePicker,
FilesOpenExternal,
FilesOpenLocalFile,
FilesOpenPath,
FilesRevealPath,
FilesReadClipboardImage,
)
@@ -0,0 +1,29 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const DesktopMenuAction = Schema.Literals([
"app.checkForUpdates",
"app.relaunch",
"edit.undo",
"edit.redo",
"edit.cut",
"edit.copy",
"edit.paste",
"edit.delete",
"edit.selectAll",
"view.reload",
"view.toggleDevTools",
"view.resetZoom",
"view.zoomIn",
"view.zoomOut",
"view.toggleFullscreen",
"window.new",
"window.close",
"window.minimize",
"window.toggleMaximize",
])
export const MenuRunAction = Rpc.make("MenuRunAction", {
payload: { action: DesktopMenuAction },
})
export const MenuRpcs = RpcGroup.make(MenuRunAction)
@@ -0,0 +1,52 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const StorageGet = Rpc.make("StorageGet", {
payload: { name: Schema.String, key: Schema.String },
success: Schema.NullOr(Schema.String),
})
export const StorageSet = Rpc.make("StorageSet", {
payload: { name: Schema.String, key: Schema.String, value: Schema.String },
})
export const StorageDelete = Rpc.make("StorageDelete", {
payload: { name: Schema.String, key: Schema.String },
})
export const StorageClear = Rpc.make("StorageClear", { payload: { name: Schema.String } })
export const StorageKeys = Rpc.make("StorageKeys", {
payload: { name: Schema.String },
success: Schema.Array(Schema.String),
})
export const StorageLength = Rpc.make("StorageLength", {
payload: { name: Schema.String },
success: Schema.Number,
})
export const DraftsGet = Rpc.make("DraftsGet", {
payload: { key: Schema.String },
success: Schema.NullOr(Schema.String),
})
export const DraftsSet = Rpc.make("DraftsSet", {
payload: { key: Schema.String, value: Schema.String },
})
export const DraftsDelete = Rpc.make("DraftsDelete", { payload: { key: Schema.String } })
export const DraftsPutBlob = Rpc.make("DraftsPutBlob", {
payload: { data: Schema.Uint8Array },
success: Schema.String,
})
export const DraftsGetBlob = Rpc.make("DraftsGetBlob", {
payload: { id: Schema.String },
success: Schema.NullOr(Schema.Uint8Array),
})
export const StorageRpcs = RpcGroup.make(
StorageGet,
StorageSet,
StorageDelete,
StorageClear,
StorageKeys,
StorageLength,
DraftsGet,
DraftsSet,
DraftsDelete,
DraftsPutBlob,
DraftsGetBlob,
)
@@ -0,0 +1,19 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const UpdaterStateSchema = Schema.Union([
Schema.Struct({ status: Schema.Literal("disabled") }),
Schema.Struct({ status: Schema.Literal("idle") }),
Schema.Struct({ status: Schema.Literal("checking") }),
Schema.Struct({ status: Schema.Literal("downloading"), version: Schema.String }),
Schema.Struct({ status: Schema.Literal("ready"), version: Schema.String }),
Schema.Struct({ status: Schema.Literal("up-to-date") }),
Schema.Struct({ status: Schema.Literal("installing"), version: Schema.String }),
Schema.Struct({ status: Schema.Literal("error"), message: Schema.String }),
])
export const UpdaterSubscribe = Rpc.make("UpdaterSubscribe")
export const UpdaterUnsubscribe = Rpc.make("UpdaterUnsubscribe")
export const UpdaterCheck = Rpc.make("UpdaterCheck", { success: UpdaterStateSchema })
export const UpdaterInstall = Rpc.make("UpdaterInstall")
export const UpdaterRpcs = RpcGroup.make(UpdaterSubscribe, UpdaterUnsubscribe, UpdaterCheck, UpdaterInstall)
@@ -0,0 +1,40 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
export const WindowGetId = Rpc.make("WindowGetId", { success: Schema.String })
export const WindowThemeReady = Rpc.make("WindowThemeReady")
export const WindowGetFocused = Rpc.make("WindowGetFocused", { success: Schema.Boolean })
export const WindowGetFullscreen = Rpc.make("WindowGetFullscreen", { success: Schema.Boolean })
export const WindowSetFocus = Rpc.make("WindowSetFocus")
export const WindowShow = Rpc.make("WindowShow")
export const WindowGetZoomFactor = Rpc.make("WindowGetZoomFactor", { success: Schema.Number })
export const WindowSetZoomFactor = Rpc.make("WindowSetZoomFactor", {
payload: { factor: Schema.Number },
})
export const WindowGetPinchZoomEnabled = Rpc.make("WindowGetPinchZoomEnabled", {
success: Schema.Boolean,
})
export const WindowSetPinchZoomEnabled = Rpc.make("WindowSetPinchZoomEnabled", {
payload: { enabled: Schema.Boolean },
})
export const WindowSetTitlebar = Rpc.make("WindowSetTitlebar", {
payload: {
theme: Schema.Struct({
mode: Schema.Literals(["light", "dark"]),
scheme: Schema.optionalKey(Schema.Literals(["system", "light", "dark"])),
}),
},
})
export const WindowRpcs = RpcGroup.make(
WindowGetId,
WindowThemeReady,
WindowGetFocused,
WindowGetFullscreen,
WindowSetFocus,
WindowShow,
WindowGetZoomFactor,
WindowSetZoomFactor,
WindowGetPinchZoomEnabled,
WindowSetPinchZoomEnabled,
WindowSetTitlebar,
)
+109
View File
@@ -0,0 +1,109 @@
import { Schema } from "effect"
import { Rpc, RpcGroup } from "effect/unstable/rpc"
const WslServerConfig = Schema.Struct({ id: Schema.String, distro: Schema.String })
const WslServerRuntime = Schema.Union([
Schema.Struct({ kind: Schema.Literal("starting") }),
Schema.Struct({
kind: Schema.Literal("ready"),
url: Schema.String,
username: Schema.NullOr(Schema.String),
password: Schema.NullOr(Schema.String),
}),
Schema.Struct({ kind: Schema.Literal("failed"), message: Schema.String }),
Schema.Struct({ kind: Schema.Literal("stopped") }),
])
const WslJob = Schema.Union([
Schema.Struct({ kind: Schema.Literal("runtime"), startedAt: Schema.Number }),
Schema.Struct({ kind: Schema.Literal("distros"), startedAt: Schema.Number }),
Schema.Struct({ kind: Schema.Literal("install-wsl"), startedAt: Schema.Number }),
Schema.Struct({ kind: Schema.Literal("install-distro"), distro: Schema.String, startedAt: Schema.Number }),
Schema.Struct({
kind: Schema.Literal("probe-addable"),
distros: Schema.Array(Schema.String),
startedAt: Schema.Number,
}),
Schema.Struct({ kind: Schema.Literal("install-opencode"), distro: Schema.String, startedAt: Schema.Number }),
])
const WslServersState = Schema.Struct({
runtime: Schema.NullOr(
Schema.Struct({
available: Schema.Boolean,
version: Schema.NullOr(Schema.String),
error: Schema.NullOr(Schema.String),
}),
),
installed: Schema.Array(
Schema.Struct({ name: Schema.String, version: Schema.NullOr(Schema.Number), isDefault: Schema.Boolean }),
),
online: Schema.Array(Schema.Struct({ name: Schema.String, label: Schema.String })),
distroProbes: Schema.Record(
Schema.String,
Schema.Struct({
name: Schema.String,
canExecute: Schema.Boolean,
hasBash: Schema.Boolean,
hasCurl: Schema.Boolean,
error: Schema.NullOr(Schema.String),
}),
),
opencodeChecks: Schema.Record(
Schema.String,
Schema.Struct({
distro: Schema.String,
resolvedPath: Schema.NullOr(Schema.String),
version: Schema.NullOr(Schema.String),
expectedVersion: Schema.NullOr(Schema.String),
matchesDesktop: Schema.NullOr(Schema.Boolean),
error: Schema.NullOr(Schema.String),
}),
),
pendingRestart: Schema.Boolean,
servers: Schema.Array(Schema.Struct({ config: WslServerConfig, runtime: WslServerRuntime })),
job: Schema.NullOr(WslJob),
})
export const WslServersEventSchema = Schema.Struct({ type: Schema.Literal("state"), state: WslServersState })
export const WslSubscribe = Rpc.make("WslSubscribe")
export const WslUnsubscribe = Rpc.make("WslUnsubscribe")
export const WslGetState = Rpc.make("WslGetState", { success: WslServersState })
export const WslProbeRuntime = Rpc.make("WslProbeRuntime")
export const WslRefreshDistros = Rpc.make("WslRefreshDistros")
export const WslInstallWsl = Rpc.make("WslInstallWsl")
export const WslInstallDistro = Rpc.make("WslInstallDistro", {
payload: { name: Schema.String },
})
export const WslProbeAddable = Rpc.make("WslProbeAddable", {
payload: { distros: Schema.Array(Schema.String) },
})
export const WslInstallOpencode = Rpc.make("WslInstallOpencode", {
payload: { name: Schema.String },
})
export const WslOpenTerminal = Rpc.make("WslOpenTerminal", {
payload: { name: Schema.String },
})
export const WslAddServer = Rpc.make("WslAddServer", {
payload: { distro: Schema.String },
success: WslServerConfig,
})
export const WslRemoveServer = Rpc.make("WslRemoveServer", {
payload: { id: Schema.String },
})
export const WslStartServer = Rpc.make("WslStartServer", {
payload: { id: Schema.String },
})
export const WslRpcs = RpcGroup.make(
WslSubscribe,
WslUnsubscribe,
WslGetState,
WslProbeRuntime,
WslRefreshDistros,
WslInstallWsl,
WslInstallDistro,
WslProbeAddable,
WslInstallOpencode,
WslOpenTerminal,
WslAddServer,
WslRemoveServer,
WslStartServer,
)
@@ -0,0 +1 @@
export const IpcTransportPort = "desktop-rpc-port"
+1 -1
View File
@@ -2941,7 +2941,7 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
</Show>
{props.children}
<Show when={error()}>
<text fg={theme.text.subdued}>{error()}</text>
<text fg={theme.text.feedback.error.default}>{error()}</text>
</Show>
</box>
)
+9 -1
View File
@@ -22,6 +22,7 @@ import { makeGlobalNode } from "./effect/app-node.js"
import { filesystem, path } from "./effect/app-node-platform.js"
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
const nativeWindowsExtensions = new Set([".com", ".exe"])
const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => {
switch (err.code) {
@@ -261,7 +262,14 @@ const makeCrossSpawnSpawner = Effect.gen(function* () {
const launchProcess = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const proc = launch(command.command, command.args, opts)
const native =
process.platform === "win32" &&
!opts.shell &&
path.isAbsolute(command.command) &&
nativeWindowsExtensions.has(path.extname(command.command).toLowerCase())
const proc = native
? NodeChildProcess.spawn(command.command, command.args, opts)
: launch(command.command, command.args, opts)
let end = false
let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined
proc.on("error", (err) => {