Fix/reverception (#13166)

Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
Filip
2026-02-11 16:18:44 +01:00
committed by GitHub
parent fc88dde63f
commit eef3ae3e1f
28 changed files with 774 additions and 702 deletions

View File

@@ -43,7 +43,7 @@ function UiI18nBridge(props: ParentProps) {
declare global { declare global {
interface Window { interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] } __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
} }
} }

View File

@@ -1,4 +1,4 @@
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
@@ -18,7 +18,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { AppIcon } from "@opencode-ai/ui/app-icon" import { AppIcon } from "@opencode-ai/ui/app-icon"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Popover } from "@opencode-ai/ui/popover" import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
@@ -168,7 +167,6 @@ export function SessionHeader() {
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false }) const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({ app: undefined as OpenApp | undefined, version: 0 })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
@@ -181,32 +179,20 @@ export function SessionHeader() {
setPrefs("app", options()[0]?.id ?? "finder") setPrefs("app", options()[0]?.id ?? "finder")
}) })
const [openTask] = createResource( const openDir = (app: OpenApp) => {
() => openRequest.app && openRequest.version,
async () => {
const app = openRequest.app
const directory = projectDirectory() const directory = projectDirectory()
if (!app || !directory || !canOpen()) return if (!directory) return
if (!canOpen()) return
const item = options().find((o) => o.id === app) const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined const openWith = item && "openWith" in item ? item.openWith : undefined
await platform.openPath?.(directory, openWith) Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
},
)
createEffect(() => {
const err = openTask.error
if (!err) return
showToast({ showToast({
variant: "error", variant: "error",
title: language.t("common.requestFailed"), title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err), description: err instanceof Error ? err.message : String(err),
}) })
}) })
const openDir = (app: OpenApp) => {
if (openTask.loading) return
setOpenRequest({ app, version: openRequest.version + 1 })
} }
const copyPath = () => { const copyPath = () => {
@@ -362,18 +348,12 @@ export function SessionHeader() {
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden"> <div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
<Button <Button
variant="ghost" variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none disabled:!cursor-default" class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
classList={{
"bg-surface-raised-base-active": openTask.loading,
}}
onClick={() => openDir(current().id)} onClick={() => openDir(current().id)}
disabled={openTask.loading}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })} aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
> >
<div class="flex size-5 shrink-0 items-center justify-center"> <div class="flex size-5 shrink-0 items-center justify-center">
<Show when={openTask.loading} fallback={<AppIcon id={current().icon} class="size-4" />}> <AppIcon id={current().icon} class="size-4" />
<Spinner class="size-3.5 text-icon-base" />
</Show>
</div> </div>
<span class="text-12-regular text-text-strong">Open</span> <span class="text-12-regular text-text-strong">Open</span>
</Button> </Button>
@@ -388,11 +368,7 @@ export function SessionHeader() {
as={IconButton} as={IconButton}
icon="chevron-down" icon="chevron-down"
variant="ghost" variant="ghost"
disabled={openTask.loading} class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": openTask.loading,
}}
aria-label={language.t("session.header.open.menu")} aria-label={language.t("session.header.open.menu")}
/> />
<DropdownMenu.Portal> <DropdownMenu.Portal>
@@ -409,7 +385,6 @@ export function SessionHeader() {
{options().map((o) => ( {options().map((o) => (
<DropdownMenu.RadioItem <DropdownMenu.RadioItem
value={o.id} value={o.id}
disabled={openTask.loading}
onSelect={() => { onSelect={() => {
setMenu("open", false) setMenu("open", false)
openDir(o.id) openDir(o.id)

View File

@@ -367,6 +367,34 @@ export const SettingsGeneral: Component = () => {
</div> </div>
</div> </div>
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
return (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.desktop.wsl.title")}
description={language.t("settings.desktop.wsl.description")}
>
<div data-action="settings-wsl">
<Switch
checked={enabled() ?? false}
disabled={enabledResource.state === "pending"}
onChange={(checked) => platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
/>
</div>
</SettingsRow>
</div>
</div>
)
}}
</Show>
{/* Updates Section */} {/* Updates Section */}
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3> <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>

View File

@@ -57,6 +57,12 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */ /** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise<void> | void setDefaultServerUrl?(url: string | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>
/** Set the configured WSL integration (desktop only) */
setWslEnabled?(config: boolean): Promise<void> | void
/** Get the preferred display backend (desktop only) */ /** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null

View File

@@ -508,6 +508,9 @@ export const dict = {
"settings.section.server": "الخادم", "settings.section.server": "الخادم",
"settings.tab.general": "عام", "settings.tab.general": "عام",
"settings.tab.shortcuts": "اختصارات", "settings.tab.shortcuts": "اختصارات",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "المظهر", "settings.general.section.appearance": "المظهر",
"settings.general.section.notifications": "إشعارات النظام", "settings.general.section.notifications": "إشعارات النظام",

View File

@@ -512,6 +512,9 @@ export const dict = {
"settings.section.server": "Servidor", "settings.section.server": "Servidor",
"settings.tab.general": "Geral", "settings.tab.general": "Geral",
"settings.tab.shortcuts": "Atalhos", "settings.tab.shortcuts": "Atalhos",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Aparência", "settings.general.section.appearance": "Aparência",
"settings.general.section.notifications": "Notificações do sistema", "settings.general.section.notifications": "Notificações do sistema",

View File

@@ -539,6 +539,9 @@ export const dict = {
"settings.section.server": "Server", "settings.section.server": "Server",
"settings.tab.general": "Opšte", "settings.tab.general": "Opšte",
"settings.tab.shortcuts": "Prečice", "settings.tab.shortcuts": "Prečice",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Izgled", "settings.general.section.appearance": "Izgled",
"settings.general.section.notifications": "Sistemske obavijesti", "settings.general.section.notifications": "Sistemske obavijesti",

View File

@@ -512,6 +512,9 @@ export const dict = {
"settings.section.server": "Server", "settings.section.server": "Server",
"settings.tab.general": "Generelt", "settings.tab.general": "Generelt",
"settings.tab.shortcuts": "Genveje", "settings.tab.shortcuts": "Genveje",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Udseende", "settings.general.section.appearance": "Udseende",
"settings.general.section.notifications": "Systemmeddelelser", "settings.general.section.notifications": "Systemmeddelelser",

View File

@@ -556,6 +556,9 @@ export const dict = {
"settings.section.server": "Server", "settings.section.server": "Server",
"settings.tab.general": "Allgemein", "settings.tab.general": "Allgemein",
"settings.tab.shortcuts": "Tastenkombinationen", "settings.tab.shortcuts": "Tastenkombinationen",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Erscheinungsbild", "settings.general.section.appearance": "Erscheinungsbild",
"settings.general.section.notifications": "Systembenachrichtigungen", "settings.general.section.notifications": "Systembenachrichtigungen",

View File

@@ -583,6 +583,9 @@ export const dict = {
"settings.section.server": "Server", "settings.section.server": "Server",
"settings.tab.general": "General", "settings.tab.general": "General",
"settings.tab.shortcuts": "Shortcuts", "settings.tab.shortcuts": "Shortcuts",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Appearance", "settings.general.section.appearance": "Appearance",
"settings.general.section.notifications": "System notifications", "settings.general.section.notifications": "System notifications",

View File

@@ -515,6 +515,9 @@ export const dict = {
"settings.section.server": "Servidor", "settings.section.server": "Servidor",
"settings.tab.general": "General", "settings.tab.general": "General",
"settings.tab.shortcuts": "Atajos", "settings.tab.shortcuts": "Atajos",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Apariencia", "settings.general.section.appearance": "Apariencia",
"settings.general.section.notifications": "Notificaciones del sistema", "settings.general.section.notifications": "Notificaciones del sistema",

View File

@@ -522,6 +522,9 @@ export const dict = {
"settings.section.server": "Serveur", "settings.section.server": "Serveur",
"settings.tab.general": "Général", "settings.tab.general": "Général",
"settings.tab.shortcuts": "Raccourcis", "settings.tab.shortcuts": "Raccourcis",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Apparence", "settings.general.section.appearance": "Apparence",
"settings.general.section.notifications": "Notifications système", "settings.general.section.notifications": "Notifications système",

View File

@@ -507,6 +507,9 @@ export const dict = {
"settings.section.server": "サーバー", "settings.section.server": "サーバー",
"settings.tab.general": "一般", "settings.tab.general": "一般",
"settings.tab.shortcuts": "ショートカット", "settings.tab.shortcuts": "ショートカット",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "外観", "settings.general.section.appearance": "外観",
"settings.general.section.notifications": "システム通知", "settings.general.section.notifications": "システム通知",

View File

@@ -513,6 +513,9 @@ export const dict = {
"settings.section.server": "서버", "settings.section.server": "서버",
"settings.tab.general": "일반", "settings.tab.general": "일반",
"settings.tab.shortcuts": "단축키", "settings.tab.shortcuts": "단축키",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "모양", "settings.general.section.appearance": "모양",
"settings.general.section.notifications": "시스템 알림", "settings.general.section.notifications": "시스템 알림",

View File

@@ -515,6 +515,9 @@ export const dict = {
"settings.section.server": "Server", "settings.section.server": "Server",
"settings.tab.general": "Generelt", "settings.tab.general": "Generelt",
"settings.tab.shortcuts": "Snarveier", "settings.tab.shortcuts": "Snarveier",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Utseende", "settings.general.section.appearance": "Utseende",
"settings.general.section.notifications": "Systemvarsler", "settings.general.section.notifications": "Systemvarsler",

View File

@@ -514,6 +514,9 @@ export const dict = {
"settings.section.server": "Serwer", "settings.section.server": "Serwer",
"settings.tab.general": "Ogólne", "settings.tab.general": "Ogólne",
"settings.tab.shortcuts": "Skróty", "settings.tab.shortcuts": "Skróty",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Wygląd", "settings.general.section.appearance": "Wygląd",
"settings.general.section.notifications": "Powiadomienia systemowe", "settings.general.section.notifications": "Powiadomienia systemowe",

View File

@@ -517,6 +517,9 @@ export const dict = {
"settings.section.server": "Сервер", "settings.section.server": "Сервер",
"settings.tab.general": "Основные", "settings.tab.general": "Основные",
"settings.tab.shortcuts": "Горячие клавиши", "settings.tab.shortcuts": "Горячие клавиши",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Внешний вид", "settings.general.section.appearance": "Внешний вид",
"settings.general.section.notifications": "Системные уведомления", "settings.general.section.notifications": "Системные уведомления",

View File

@@ -516,6 +516,9 @@ export const dict = {
"settings.section.server": "เซิร์ฟเวอร์", "settings.section.server": "เซิร์ฟเวอร์",
"settings.tab.general": "ทั่วไป", "settings.tab.general": "ทั่วไป",
"settings.tab.shortcuts": "ทางลัด", "settings.tab.shortcuts": "ทางลัด",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "รูปลักษณ์", "settings.general.section.appearance": "รูปลักษณ์",
"settings.general.section.notifications": "การแจ้งเตือนระบบ", "settings.general.section.notifications": "การแจ้งเตือนระบบ",

View File

@@ -548,6 +548,9 @@ export const dict = {
"settings.section.server": "服务器", "settings.section.server": "服务器",
"settings.tab.general": "通用", "settings.tab.general": "通用",
"settings.tab.shortcuts": "快捷键", "settings.tab.shortcuts": "快捷键",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "外观", "settings.general.section.appearance": "外观",
"settings.general.section.notifications": "系统通知", "settings.general.section.notifications": "系统通知",

View File

@@ -545,6 +545,9 @@ export const dict = {
"settings.section.server": "伺服器", "settings.section.server": "伺服器",
"settings.tab.general": "一般", "settings.tab.general": "一般",
"settings.tab.shortcuts": "快速鍵", "settings.tab.shortcuts": "快速鍵",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "外觀", "settings.general.section.appearance": "外觀",
"settings.general.section.notifications": "系統通知", "settings.general.section.notifications": "系統通知",

View File

@@ -0,0 +1,4 @@
# Desktop package notes
- Never call `invoke` manually in this package.
- Use the generated bindings in `packages/desktop/src/bindings.ts` for core commands/events.

View File

@@ -3,8 +3,11 @@ use tauri_plugin_shell::{
ShellExt, ShellExt,
process::{Command, CommandChild, CommandEvent, TerminatedPayload}, process::{Command, CommandChild, CommandEvent, TerminatedPayload},
}; };
use tauri_plugin_store::StoreExt;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY};
const CLI_INSTALL_DIR: &str = ".opencode/bin"; const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode"; const CLI_BINARY_NAME: &str = "opencode";
@@ -20,7 +23,7 @@ pub struct Config {
} }
pub async fn get_config(app: &AppHandle) -> Option<Config> { pub async fn get_config(app: &AppHandle) -> Option<Config> {
create_command(app, "debug config") create_command(app, "debug config", &[])
.output() .output()
.await .await
.inspect_err(|e| tracing::warn!("Failed to read OC config: {e}")) .inspect_err(|e| tracing::warn!("Failed to read OC config: {e}"))
@@ -149,25 +152,106 @@ fn get_user_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
} }
pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command { fn is_wsl_enabled(app: &tauri::AppHandle) -> bool {
let Ok(store) = app.store(SETTINGS_STORE) else {
return false;
};
store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn shell_escape(input: &str) -> String {
if input.is_empty() {
return "''".to_string();
}
let mut escaped = String::from("'");
escaped.push_str(&input.replace("'", "'\"'\"'"));
escaped.push('\'');
escaped
}
pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, String)]) -> Command {
let state_dir = app let state_dir = app
.path() .path()
.resolve("", BaseDirectory::AppLocalData) .resolve("", BaseDirectory::AppLocalData)
.expect("Failed to resolve app local data dir"); .expect("Failed to resolve app local data dir");
#[cfg(target_os = "windows")] let mut envs = vec![
(
"OPENCODE_EXPERIMENTAL_ICON_DISCOVERY".to_string(),
"true".to_string(),
),
(
"OPENCODE_EXPERIMENTAL_FILEWATCHER".to_string(),
"true".to_string(),
),
("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
(
"XDG_STATE_HOME".to_string(),
state_dir.to_string_lossy().to_string(),
),
];
envs.extend(
extra_env
.iter()
.map(|(key, value)| (key.to_string(), value.clone())),
);
if cfg!(windows) {
if is_wsl_enabled(app) {
tracing::info!("WSL is enabled, spawning CLI server in WSL");
let version = app.package_info().version.to_string();
let mut script = vec![
"set -e".to_string(),
"BIN=\"$HOME/.opencode/bin/opencode\"".to_string(),
"if [ ! -x \"$BIN\" ]; then".to_string(),
format!(
" curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path",
shell_escape(&version)
),
"fi".to_string(),
];
let mut env_prefix = vec![
"OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true".to_string(),
"OPENCODE_EXPERIMENTAL_FILEWATCHER=true".to_string(),
"OPENCODE_CLIENT=desktop".to_string(),
"XDG_STATE_HOME=\"$HOME/.local/state\"".to_string(),
];
env_prefix.extend(
envs.iter()
.filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
.filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_FILEWATCHER")
.filter(|(key, _)| key != "OPENCODE_CLIENT")
.filter(|(key, _)| key != "XDG_STATE_HOME")
.map(|(key, value)| format!("{}={}", key, shell_escape(value))),
);
script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args));
return app return app
.shell()
.command("wsl")
.args(["-e", "bash", "-lc", &script.join("\n")]);
} else {
let mut cmd = app
.shell() .shell()
.sidecar("opencode-cli") .sidecar("opencode-cli")
.unwrap() .unwrap()
.args(args.split_whitespace()) .args(args.split_whitespace());
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir);
#[cfg(not(target_os = "windows"))] for (key, value) in envs {
return { cmd = cmd.env(key, value);
}
return cmd;
}
} else {
let sidecar = get_sidecar_path(app); let sidecar = get_sidecar_path(app);
let shell = get_user_shell(); let shell = get_user_shell();
@@ -177,14 +261,14 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
format!("\"{}\" {}", sidecar.display(), args) format!("\"{}\" {}", sidecar.display(), args)
}; };
app.shell() let mut cmd = app.shell().command(&shell).args(["-il", "-c", &cmd]);
.command(&shell)
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") for (key, value) in envs {
.env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true") cmd = cmd.env(key, value);
.env("OPENCODE_CLIENT", "desktop") }
.env("XDG_STATE_HOME", &state_dir)
.args(["-il", "-c", &cmd]) cmd
}; }
} }
pub fn serve( pub fn serve(
@@ -197,12 +281,16 @@ pub fn serve(
tracing::info!(port, "Spawning sidecar"); tracing::info!(port, "Spawning sidecar");
let envs = [
("OPENCODE_SERVER_USERNAME", "opencode".to_string()),
("OPENCODE_SERVER_PASSWORD", password.to_string()),
];
let (mut rx, child) = create_command( let (mut rx, child) = create_command(
app, app,
format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(), format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(),
&envs,
) )
.env("OPENCODE_SERVER_USERNAME", "opencode")
.env("OPENCODE_SERVER_PASSWORD", password)
.spawn() .spawn()
.expect("Failed to spawn opencode"); .expect("Failed to spawn opencode");

View File

@@ -2,6 +2,7 @@ use tauri_plugin_window_state::StateFlags;
pub const SETTINGS_STORE: &str = "opencode.settings.dat"; pub const SETTINGS_STORE: &str = "opencode.settings.dat";
pub const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; pub const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
pub const WSL_ENABLED_KEY: &str = "wslEnabled";
pub const UPDATER_ENABLED: bool = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); pub const UPDATER_ENABLED: bool = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
pub fn window_state_flags() -> StateFlags { pub fn window_state_flags() -> StateFlags {

View File

@@ -52,6 +52,13 @@ enum InitStep {
Done, Done,
} }
#[derive(serde::Deserialize, specta::Type)]
#[serde(rename_all = "snake_case")]
enum WslPathMode {
Windows,
Linux,
}
struct InitState { struct InitState {
current: watch::Receiver<InitStep>, current: watch::Receiver<InitStep>,
} }
@@ -155,211 +162,28 @@ fn check_app_exists(app_name: &str) -> bool {
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn check_windows_app(app_name: &str) -> bool { fn check_windows_app(_app_name: &str) -> bool {
resolve_windows_app_path(app_name).is_some() // Check if command exists in PATH, including .exe
return true;
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn resolve_windows_app_path(app_name: &str) -> Option<String> { fn resolve_windows_app_path(app_name: &str) -> Option<String> {
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
fn expand_env(value: &str) -> String { // Try to find the command using 'where'
let mut out = String::with_capacity(value.len()); let output = Command::new("where").arg(app_name).output().ok()?;
let mut index = 0;
while let Some(start) = value[index..].find('%') {
let start = index + start;
out.push_str(&value[index..start]);
let Some(end_rel) = value[start + 1..].find('%') else {
out.push_str(&value[start..]);
return out;
};
let end = start + 1 + end_rel;
let key = &value[start + 1..end];
if key.is_empty() {
out.push('%');
index = end + 1;
continue;
}
if let Ok(v) = std::env::var(key) {
out.push_str(&v);
index = end + 1;
continue;
}
out.push_str(&value[start..=end]);
index = end + 1;
}
out.push_str(&value[index..]);
out
}
fn extract_exe(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Some(rest) = value.strip_prefix('"') {
if let Some(end) = rest.find('"') {
let inner = rest[..end].trim();
if inner.to_ascii_lowercase().contains(".exe") {
return Some(inner.to_string());
}
}
}
let lower = value.to_ascii_lowercase();
let end = lower.find(".exe")?;
Some(value[..end + 4].trim().trim_matches('"').to_string())
}
fn candidates(app_name: &str) -> Vec<String> {
let app_name = app_name.trim().trim_matches('"');
if app_name.is_empty() {
return vec![];
}
let mut out = Vec::<String>::new();
let mut push = |value: String| {
let value = value.trim().trim_matches('"').to_string();
if value.is_empty() {
return;
}
if out.iter().any(|v| v.eq_ignore_ascii_case(&value)) {
return;
}
out.push(value);
};
push(app_name.to_string());
let lower = app_name.to_ascii_lowercase();
if !lower.ends_with(".exe") {
push(format!("{app_name}.exe"));
}
let snake = {
let mut s = String::new();
let mut underscore = false;
for c in lower.chars() {
if c.is_ascii_alphanumeric() {
s.push(c);
underscore = false;
continue;
}
if underscore {
continue;
}
s.push('_');
underscore = true;
}
s.trim_matches('_').to_string()
};
if !snake.is_empty() {
push(snake.clone());
if !snake.ends_with(".exe") {
push(format!("{snake}.exe"));
}
}
let alnum = lower
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect::<String>();
if !alnum.is_empty() {
push(alnum.clone());
push(format!("{alnum}.exe"));
}
match lower.as_str() {
"sublime text" | "sublime-text" | "sublime_text" | "sublime text.exe" => {
push("subl".to_string());
push("subl.exe".to_string());
push("sublime_text".to_string());
push("sublime_text.exe".to_string());
}
_ => {}
}
out
}
fn reg_app_path(exe: &str) -> Option<String> {
let exe = exe.trim().trim_matches('"');
if exe.is_empty() {
return None;
}
let keys = [
format!(
r"HKCU\Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"
),
format!(
r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"
),
format!(
r"HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\{exe}"
),
];
for key in keys {
let Some(output) = Command::new("reg")
.args(["query", &key, "/ve"])
.output()
.ok()
else {
continue;
};
if !output.status.success() { if !output.status.success() {
continue;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let tokens = line.split_whitespace().collect::<Vec<_>>();
let Some(index) = tokens.iter().position(|v| v.starts_with("REG_")) else {
continue;
};
let value = tokens[index + 1..].join(" ");
let Some(exe) = extract_exe(&value) else {
continue;
};
let exe = expand_env(&exe);
let path = Path::new(exe.trim().trim_matches('"'));
if path.exists() {
return Some(path.to_string_lossy().to_string());
}
}
}
None
}
let app_name = app_name.trim().trim_matches('"');
if app_name.is_empty() {
return None; return None;
} }
let direct = Path::new(app_name); let paths = String::from_utf8_lossy(&output.stdout)
if direct.is_absolute() && direct.exists() { .lines()
return Some(direct.to_string_lossy().to_string()); .map(str::trim)
} .filter(|line| !line.is_empty())
.map(PathBuf::from)
let key = app_name .collect::<Vec<_>>();
.chars()
.filter(|v| v.is_ascii_alphanumeric())
.flat_map(|v| v.to_lowercase())
.collect::<String>();
let has_ext = |path: &Path, ext: &str| { let has_ext = |path: &Path, ext: &str| {
path.extension() path.extension()
@@ -368,19 +192,22 @@ fn resolve_windows_app_path(app_name: &str) -> Option<String> {
.unwrap_or(false) .unwrap_or(false)
}; };
if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
return Some(path.to_string_lossy().to_string());
}
let resolve_cmd = |path: &Path| -> Option<String> { let resolve_cmd = |path: &Path| -> Option<String> {
let bytes = std::fs::read(path).ok()?; let content = std::fs::read_to_string(path).ok()?;
let content = String::from_utf8_lossy(&bytes);
for token in content.split('"') { for token in content.split('"') {
let Some(exe) = extract_exe(token) else { let lower = token.to_ascii_lowercase();
if !lower.contains(".exe") {
continue; continue;
}; }
let lower = exe.to_ascii_lowercase();
if let Some(index) = lower.find("%~dp0") { if let Some(index) = lower.find("%~dp0") {
let base = path.parent()?; let base = path.parent()?;
let suffix = &exe[index + 5..]; let suffix = &token[index + 5..];
let mut resolved = PathBuf::from(base); let mut resolved = PathBuf::from(base);
for part in suffix.replace('/', "\\").split('\\') { for part in suffix.replace('/', "\\").split('\\') {
@@ -397,11 +224,9 @@ fn resolve_windows_app_path(app_name: &str) -> Option<String> {
if resolved.exists() { if resolved.exists() {
return Some(resolved.to_string_lossy().to_string()); return Some(resolved.to_string_lossy().to_string());
} }
continue;
} }
let resolved = PathBuf::from(expand_env(&exe)); let resolved = PathBuf::from(token);
if resolved.exists() { if resolved.exists() {
return Some(resolved.to_string_lossy().to_string()); return Some(resolved.to_string_lossy().to_string());
} }
@@ -410,27 +235,6 @@ fn resolve_windows_app_path(app_name: &str) -> Option<String> {
None None
}; };
let resolve_where = |query: &str| -> Option<String> {
let output = Command::new("where").arg(query).output().ok()?;
if !output.status.success() {
return None;
}
let paths = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect::<Vec<_>>();
if paths.is_empty() {
return None;
}
if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
return Some(path.to_string_lossy().to_string());
}
for path in &paths { for path in &paths {
if has_ext(path, "cmd") || has_ext(path, "bat") { if has_ext(path, "cmd") || has_ext(path, "bat") {
if let Some(resolved) = resolve_cmd(path) { if let Some(resolved) = resolve_cmd(path) {
@@ -455,6 +259,12 @@ fn resolve_windows_app_path(app_name: &str) -> Option<String> {
} }
} }
let key = app_name
.chars()
.filter(|v| v.is_ascii_alphanumeric())
.flat_map(|v| v.to_lowercase())
.collect::<String>();
if !key.is_empty() { if !key.is_empty() {
for path in &paths { for path in &paths {
let dirs = [ let dirs = [
@@ -493,47 +303,6 @@ fn resolve_windows_app_path(app_name: &str) -> Option<String> {
} }
paths.first().map(|path| path.to_string_lossy().to_string()) paths.first().map(|path| path.to_string_lossy().to_string())
};
let list = candidates(app_name);
for query in &list {
if let Some(path) = resolve_where(query) {
return Some(path);
}
}
let mut exes = Vec::<String>::new();
for query in &list {
let query = query.trim().trim_matches('"');
if query.is_empty() {
continue;
}
let name = Path::new(query)
.file_name()
.and_then(|v| v.to_str())
.unwrap_or(query);
let exe = if name.to_ascii_lowercase().ends_with(".exe") {
name.to_string()
} else {
format!("{name}.exe")
};
if exes.iter().any(|v| v.eq_ignore_ascii_case(&exe)) {
continue;
}
exes.push(exe);
}
for exe in exes {
if let Some(path) = reg_app_path(&exe) {
return Some(path);
}
}
None
} }
#[tauri::command] #[tauri::command]
@@ -620,32 +389,50 @@ fn check_linux_app(app_name: &str) -> bool {
return true; return true;
} }
#[tauri::command]
#[specta::specta]
fn wsl_path(path: String, mode: Option<WslPathMode>) -> Result<String, String> {
if !cfg!(windows) {
return Ok(path);
}
let flag = match mode.unwrap_or(WslPathMode::Linux) {
WslPathMode::Windows => "-w",
WslPathMode::Linux => "-u",
};
let output = if path.starts_with('~') {
let suffix = path.strip_prefix('~').unwrap_or("");
let escaped = suffix.replace('"', "\\\"");
let cmd = format!("wslpath {flag} \"$HOME{escaped}\"");
Command::new("wsl")
.args(["-e", "sh", "-lc", &cmd])
.output()
.map_err(|e| format!("Failed to run wslpath: {e}"))?
} else {
Command::new("wsl")
.args(["-e", "wslpath", flag, &path])
.output()
.map_err(|e| format!("Failed to run wslpath: {e}"))?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err("wslpath failed".to_string());
}
return Err(stderr);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let builder = tauri_specta::Builder::<tauri::Wry>::new() let builder = make_specta_builder();
// Then register them (separated by a comma)
.commands(tauri_specta::collect_commands![
kill_sidecar,
cli::install_cli,
await_initialization,
server::get_default_server_url,
server::set_default_server_url,
get_display_backend,
set_display_backend,
markdown::parse_markdown_command,
check_app_exists,
resolve_app_path
])
.events(tauri_specta::collect_events![LoadingWindowComplete])
.error_handling(tauri_specta::ErrorHandlingMode::Throw);
#[cfg(debug_assertions)] // <- Only export on non-release builds #[cfg(debug_assertions)] // <- Only export on non-release builds
builder export_types(&builder);
.export(
specta_typescript::Typescript::default(),
"../src/bindings.ts",
)
.expect("Failed to export typescript bindings");
#[cfg(all(target_os = "macos", not(debug_assertions)))] #[cfg(all(target_os = "macos", not(debug_assertions)))]
let _ = std::process::Command::new("killall") let _ = std::process::Command::new("killall")
@@ -712,6 +499,44 @@ pub fn run() {
}); });
} }
fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(tauri_specta::collect_commands![
kill_sidecar,
cli::install_cli,
await_initialization,
server::get_default_server_url,
server::set_default_server_url,
server::get_wsl_config,
server::set_wsl_config,
get_display_backend,
set_display_backend,
markdown::parse_markdown_command,
check_app_exists,
wsl_path,
resolve_app_path
])
.events(tauri_specta::collect_events![LoadingWindowComplete])
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
}
fn export_types(builder: &tauri_specta::Builder<tauri::Wry>) {
builder
.export(
specta_typescript::Typescript::default(),
"../src/bindings.ts",
)
.expect("Failed to export typescript bindings");
}
#[cfg(test)]
#[test]
fn test_export_types() {
let builder = make_specta_builder();
export_types(&builder);
}
#[derive(tauri_specta::Event, serde::Deserialize, specta::Type)] #[derive(tauri_specta::Event, serde::Deserialize, specta::Type)]
struct LoadingWindowComplete; struct LoadingWindowComplete;

View File

@@ -8,9 +8,20 @@ use tokio::task::JoinHandle;
use crate::{ use crate::{
cli, cli,
constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE}, constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY},
}; };
#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug)]
pub struct WslConfig {
pub enabled: bool,
}
impl Default for WslConfig {
fn default() -> Self {
Self { enabled: false }
}
}
#[tauri::command] #[tauri::command]
#[specta::specta] #[specta::specta]
pub fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> { pub fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
@@ -48,6 +59,38 @@ pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Resu
Ok(()) Ok(())
} }
#[tauri::command]
#[specta::specta]
pub fn get_wsl_config(app: AppHandle) -> Result<WslConfig, String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
let enabled = store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(WslConfig { enabled })
}
#[tauri::command]
#[specta::specta]
pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
store.set(WSL_ENABLED_KEY, serde_json::Value::Bool(config.enabled));
store
.save()
.map_err(|e| format!("Failed to save settings: {}", e))?;
Ok(())
}
pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> { pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> {
if let Some(url) = get_default_server_url(app.clone()).ok().flatten() { if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
tracing::info!(%url, "Using desktop-specific custom URL"); tracing::info!(%url, "Using desktop-specific custom URL");

View File

@@ -1,4 +1,7 @@
use crate::constants::{UPDATER_ENABLED, window_state_flags}; use crate::{
constants::{UPDATER_ENABLED, window_state_flags},
server::get_wsl_config,
};
use std::{ops::Deref, time::Duration}; use std::{ops::Deref, time::Duration};
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_window_state::AppHandleExt; use tauri_plugin_window_state::AppHandleExt;
@@ -22,6 +25,11 @@ impl MainWindow {
return Ok(Self(window)); return Ok(Self(window));
} }
let wsl_enabled = get_wsl_config(app.clone())
.ok()
.map(|v| v.enabled)
.unwrap_or(false);
let window_builder = base_window_config( let window_builder = base_window_config(
WebviewWindowBuilder::new(app, Self::LABEL, WebviewUrl::App("/".into())), WebviewWindowBuilder::new(app, Self::LABEL, WebviewUrl::App("/".into())),
app, app,
@@ -36,6 +44,7 @@ impl MainWindow {
r#" r#"
window.__OPENCODE__ ??= {{}}; window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {UPDATER_ENABLED}; window.__OPENCODE__.updaterEnabled = {UPDATER_ENABLED};
window.__OPENCODE__.wsl = {wsl_enabled};
"# "#
)); ));

View File

@@ -10,10 +10,13 @@ export const commands = {
awaitInitialization: (events: Channel) => __TAURI_INVOKE<ServerReadyData>("await_initialization", { events }), awaitInitialization: (events: Channel) => __TAURI_INVOKE<ServerReadyData>("await_initialization", { events }),
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"), getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }), setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
getWslConfig: () => __TAURI_INVOKE<WslConfig>("get_wsl_config"),
setWslConfig: (config: WslConfig) => __TAURI_INVOKE<null>("set_wsl_config", { config }),
getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"), getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"),
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }), setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }), parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }), checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE<string>("wsl_path", { path, mode }),
resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }), resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }),
}; };
@@ -34,6 +37,12 @@ export type ServerReadyData = {
password: string | null, password: string | null,
}; };
export type WslConfig = {
enabled: boolean,
};
export type WslPathMode = "windows" | "linux";
/* Tauri Specta runtime */ /* Tauri Specta runtime */
function makeEvent<T>(name: string) { function makeEvent<T>(name: string) {
const base = { const base = {

View File

@@ -16,7 +16,6 @@ import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os" import { type as ostype } from "@tauri-apps/plugin-os"
import { check, Update } from "@tauri-apps/plugin-updater" import { check, Update } from "@tauri-apps/plugin-updater"
import { getCurrentWindow } from "@tauri-apps/api/window" import { getCurrentWindow } from "@tauri-apps/api/window"
import { invoke } from "@tauri-apps/api/core"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process" import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage" import { AsyncStorage } from "@solid-primitives/storage"
@@ -30,7 +29,7 @@ import { UPDATER_ENABLED } from "./updater"
import { initI18n, t } from "./i18n" import { initI18n, t } from "./i18n"
import pkg from "../package.json" import pkg from "../package.json"
import "./styles.css" import "./styles.css"
import { commands, InitStep } from "./bindings" import { commands, InitStep, type WslConfig } from "./bindings"
import { Channel } from "@tauri-apps/api/core" import { Channel } from "@tauri-apps/api/core"
import { createMenu } from "./menu" import { createMenu } from "./menu"
@@ -59,22 +58,40 @@ const listenForDeepLinks = async () => {
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined) await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
} }
const createPlatform = (password: Accessor<string | null>): Platform => ({ const createPlatform = (password: Accessor<string | null>): Platform => {
platform: "desktop", const os = (() => {
os: (() => {
const type = ostype() const type = ostype()
if (type === "macos" || type === "windows" || type === "linux") return type if (type === "macos" || type === "windows" || type === "linux") return type
return undefined return undefined
})(), })()
const wslHome = async () => {
if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined
return commands.wslPath("~", "windows").catch(() => undefined)
}
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
if (!result || !window.__OPENCODE__?.wsl) return result
if (Array.isArray(result)) {
return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any
}
return commands.wslPath(result, "linux").catch(() => result) as any
}
return {
platform: "desktop",
os,
version: pkg.version, version: pkg.version,
async openDirectoryPickerDialog(opts) { async openDirectoryPickerDialog(opts) {
const defaultPath = await wslHome()
const result = await open({ const result = await open({
directory: true, directory: true,
multiple: opts?.multiple ?? false, multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFolder"), title: opts?.title ?? t("desktop.dialog.chooseFolder"),
defaultPath,
}) })
return result return await handleWslPicker(result)
}, },
async openFilePickerDialog(opts) { async openFilePickerDialog(opts) {
@@ -83,7 +100,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
multiple: opts?.multiple ?? false, multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"), title: opts?.title ?? t("desktop.dialog.chooseFile"),
}) })
return result return handleWslPicker(result)
}, },
async saveFilePickerDialog(opts) { async saveFilePickerDialog(opts) {
@@ -91,18 +108,25 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
title: opts?.title ?? t("desktop.dialog.saveFile"), title: opts?.title ?? t("desktop.dialog.saveFile"),
defaultPath: opts?.defaultPath, defaultPath: opts?.defaultPath,
}) })
return result return handleWslPicker(result)
}, },
openLink(url: string) { openLink(url: string) {
void shellOpen(url).catch(() => undefined) void shellOpen(url).catch(() => undefined)
}, },
async openPath(path: string, app?: string) { async openPath(path: string, app?: string) {
const os = ostype() const os = ostype()
if (os === "windows" && app) { if (os === "windows") {
const resolvedApp = await commands.resolveAppPath(app) const resolvedApp = (app && (await commands.resolveAppPath(app))) || app
return openerOpenPath(path, resolvedApp || app) const resolvedPath = await (async () => {
if (window.__OPENCODE__?.wsl) {
const converted = await commands.wslPath(path, "windows").catch(() => null)
if (converted) return converted
}
return path
})()
return openerOpenPath(resolvedPath, resolvedApp)
} }
return openerOpenPath(path, app) return openerOpenPath(path, app)
}, },
@@ -342,6 +366,16 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
} }
}, },
getWslEnabled: async () => {
const next = await commands.getWslConfig().catch(() => null)
if (next) return next.enabled
return window.__OPENCODE__!.wsl ?? false
},
setWslEnabled: async (enabled) => {
await commands.setWslConfig({ enabled })
},
getDefaultServerUrl: async () => { getDefaultServerUrl: async () => {
const result = await commands.getDefaultServerUrl().catch(() => null) const result = await commands.getDefaultServerUrl().catch(() => null)
return result return result
@@ -352,12 +386,12 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
}, },
getDisplayBackend: async () => { getDisplayBackend: async () => {
const result = await invoke<DisplayBackend | null>("get_display_backend").catch(() => null) const result = await commands.getDisplayBackend().catch(() => null)
return result return result
}, },
setDisplayBackend: async (backend) => { setDisplayBackend: async (backend) => {
await invoke("set_display_backend", { backend }).catch(() => undefined) await commands.setDisplayBackend(backend)
}, },
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
@@ -390,7 +424,8 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
}, "image/png") }, "image/png")
}) })
}, },
}) }
}
let menuTrigger = null as null | ((id: string) => void) let menuTrigger = null as null | ((id: string) => void)
createMenu((id) => { createMenu((id) => {
@@ -400,6 +435,7 @@ void listenForDeepLinks()
render(() => { render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null) const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword()) const platform = createPlatform(() => serverPassword())
function handleClick(e: MouseEvent) { function handleClick(e: MouseEvent) {