Fix/reverception (#13166)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
@@ -43,7 +43,7 @@ function UiI18nBridge(props: ParentProps) {
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] }
|
||||
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { Portal } from "solid-js/web"
|
||||
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 { AppIcon } from "@opencode-ai/ui/app-icon"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
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 [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 current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
|
||||
@@ -181,32 +179,20 @@ export function SessionHeader() {
|
||||
setPrefs("app", options()[0]?.id ?? "finder")
|
||||
})
|
||||
|
||||
const [openTask] = createResource(
|
||||
() => openRequest.app && openRequest.version,
|
||||
async () => {
|
||||
const app = openRequest.app
|
||||
const directory = projectDirectory()
|
||||
if (!app || !directory || !canOpen()) return
|
||||
|
||||
const item = options().find((o) => o.id === app)
|
||||
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||
await platform.openPath?.(directory, openWith)
|
||||
},
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const err = openTask.error
|
||||
if (!err) return
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
|
||||
const openDir = (app: OpenApp) => {
|
||||
if (openTask.loading) return
|
||||
setOpenRequest({ app, version: openRequest.version + 1 })
|
||||
const directory = projectDirectory()
|
||||
if (!directory) return
|
||||
if (!canOpen()) return
|
||||
|
||||
const item = options().find((o) => o.id === app)
|
||||
const openWith = item && "openWith" in item ? item.openWith : undefined
|
||||
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": openTask.loading,
|
||||
}}
|
||||
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
|
||||
onClick={() => openDir(current().id)}
|
||||
disabled={openTask.loading}
|
||||
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
|
||||
>
|
||||
<div class="flex size-5 shrink-0 items-center justify-center">
|
||||
<Show when={openTask.loading} fallback={<AppIcon id={current().icon} class="size-4" />}>
|
||||
<Spinner class="size-3.5 text-icon-base" />
|
||||
</Show>
|
||||
<AppIcon id={current().icon} class="size-4" />
|
||||
</div>
|
||||
<span class="text-12-regular text-text-strong">Open</span>
|
||||
</Button>
|
||||
@@ -388,11 +368,7 @@ export function SessionHeader() {
|
||||
as={IconButton}
|
||||
icon="chevron-down"
|
||||
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 disabled:!cursor-default"
|
||||
classList={{
|
||||
"bg-surface-raised-base-active": openTask.loading,
|
||||
}}
|
||||
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
|
||||
aria-label={language.t("session.header.open.menu")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
@@ -409,7 +385,6 @@ export function SessionHeader() {
|
||||
{options().map((o) => (
|
||||
<DropdownMenu.RadioItem
|
||||
value={o.id}
|
||||
disabled={openTask.loading}
|
||||
onSelect={() => {
|
||||
setMenu("open", false)
|
||||
openDir(o.id)
|
||||
|
||||
@@ -367,6 +367,34 @@ export const SettingsGeneral: Component = () => {
|
||||
</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 */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
|
||||
|
||||
@@ -57,6 +57,12 @@ export type Platform = {
|
||||
/** Set the default server URL to use on app startup (platform-specific) */
|
||||
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) */
|
||||
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
|
||||
|
||||
|
||||
@@ -508,6 +508,9 @@ export const dict = {
|
||||
"settings.section.server": "الخادم",
|
||||
"settings.tab.general": "عام",
|
||||
"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.notifications": "إشعارات النظام",
|
||||
|
||||
@@ -512,6 +512,9 @@ export const dict = {
|
||||
"settings.section.server": "Servidor",
|
||||
"settings.tab.general": "Geral",
|
||||
"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.notifications": "Notificações do sistema",
|
||||
|
||||
@@ -539,6 +539,9 @@ export const dict = {
|
||||
"settings.section.server": "Server",
|
||||
"settings.tab.general": "Opšte",
|
||||
"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.notifications": "Sistemske obavijesti",
|
||||
|
||||
@@ -512,6 +512,9 @@ export const dict = {
|
||||
"settings.section.server": "Server",
|
||||
"settings.tab.general": "Generelt",
|
||||
"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.notifications": "Systemmeddelelser",
|
||||
|
||||
@@ -556,6 +556,9 @@ export const dict = {
|
||||
"settings.section.server": "Server",
|
||||
"settings.tab.general": "Allgemein",
|
||||
"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.notifications": "Systembenachrichtigungen",
|
||||
|
||||
@@ -583,6 +583,9 @@ export const dict = {
|
||||
"settings.section.server": "Server",
|
||||
"settings.tab.general": "General",
|
||||
"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.notifications": "System notifications",
|
||||
|
||||
@@ -515,6 +515,9 @@ export const dict = {
|
||||
"settings.section.server": "Servidor",
|
||||
"settings.tab.general": "General",
|
||||
"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.notifications": "Notificaciones del sistema",
|
||||
|
||||
@@ -522,6 +522,9 @@ export const dict = {
|
||||
"settings.section.server": "Serveur",
|
||||
"settings.tab.general": "Général",
|
||||
"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.notifications": "Notifications système",
|
||||
|
||||
@@ -507,6 +507,9 @@ export const dict = {
|
||||
"settings.section.server": "サーバー",
|
||||
"settings.tab.general": "一般",
|
||||
"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.notifications": "システム通知",
|
||||
|
||||
@@ -513,6 +513,9 @@ export const dict = {
|
||||
"settings.section.server": "서버",
|
||||
"settings.tab.general": "일반",
|
||||
"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.notifications": "시스템 알림",
|
||||
|
||||
@@ -515,6 +515,9 @@ export const dict = {
|
||||
"settings.section.server": "Server",
|
||||
"settings.tab.general": "Generelt",
|
||||
"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.notifications": "Systemvarsler",
|
||||
|
||||
@@ -514,6 +514,9 @@ export const dict = {
|
||||
"settings.section.server": "Serwer",
|
||||
"settings.tab.general": "Ogólne",
|
||||
"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.notifications": "Powiadomienia systemowe",
|
||||
|
||||
@@ -517,6 +517,9 @@ export const dict = {
|
||||
"settings.section.server": "Сервер",
|
||||
"settings.tab.general": "Основные",
|
||||
"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.notifications": "Системные уведомления",
|
||||
|
||||
@@ -516,6 +516,9 @@ export const dict = {
|
||||
"settings.section.server": "เซิร์ฟเวอร์",
|
||||
"settings.tab.general": "ทั่วไป",
|
||||
"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.notifications": "การแจ้งเตือนระบบ",
|
||||
|
||||
@@ -548,6 +548,9 @@ export const dict = {
|
||||
"settings.section.server": "服务器",
|
||||
"settings.tab.general": "通用",
|
||||
"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.notifications": "系统通知",
|
||||
|
||||
@@ -545,6 +545,9 @@ export const dict = {
|
||||
"settings.section.server": "伺服器",
|
||||
"settings.tab.general": "一般",
|
||||
"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.notifications": "系統通知",
|
||||
|
||||
4
packages/desktop/AGENTS.md
Normal file
4
packages/desktop/AGENTS.md
Normal 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.
|
||||
@@ -3,8 +3,11 @@ use tauri_plugin_shell::{
|
||||
ShellExt,
|
||||
process::{Command, CommandChild, CommandEvent, TerminatedPayload},
|
||||
};
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY};
|
||||
|
||||
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||
const CLI_BINARY_NAME: &str = "opencode";
|
||||
|
||||
@@ -20,7 +23,7 @@ pub struct Config {
|
||||
}
|
||||
|
||||
pub async fn get_config(app: &AppHandle) -> Option<Config> {
|
||||
create_command(app, "debug config")
|
||||
create_command(app, "debug config", &[])
|
||||
.output()
|
||||
.await
|
||||
.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())
|
||||
}
|
||||
|
||||
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
|
||||
.path()
|
||||
.resolve("", BaseDirectory::AppLocalData)
|
||||
.expect("Failed to resolve app local data dir");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return app
|
||||
.shell()
|
||||
.sidecar("opencode-cli")
|
||||
.unwrap()
|
||||
.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);
|
||||
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())),
|
||||
);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
return {
|
||||
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
|
||||
.shell()
|
||||
.command("wsl")
|
||||
.args(["-e", "bash", "-lc", &script.join("\n")]);
|
||||
} else {
|
||||
let mut cmd = app
|
||||
.shell()
|
||||
.sidecar("opencode-cli")
|
||||
.unwrap()
|
||||
.args(args.split_whitespace());
|
||||
|
||||
for (key, value) in envs {
|
||||
cmd = cmd.env(key, value);
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
} else {
|
||||
let sidecar = get_sidecar_path(app);
|
||||
let shell = get_user_shell();
|
||||
|
||||
@@ -177,14 +261,14 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
|
||||
format!("\"{}\" {}", sidecar.display(), args)
|
||||
};
|
||||
|
||||
app.shell()
|
||||
.command(&shell)
|
||||
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
|
||||
.env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true")
|
||||
.env("OPENCODE_CLIENT", "desktop")
|
||||
.env("XDG_STATE_HOME", &state_dir)
|
||||
.args(["-il", "-c", &cmd])
|
||||
};
|
||||
let mut cmd = app.shell().command(&shell).args(["-il", "-c", &cmd]);
|
||||
|
||||
for (key, value) in envs {
|
||||
cmd = cmd.env(key, value);
|
||||
}
|
||||
|
||||
cmd
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serve(
|
||||
@@ -197,12 +281,16 @@ pub fn serve(
|
||||
|
||||
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(
|
||||
app,
|
||||
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()
|
||||
.expect("Failed to spawn opencode");
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ use tauri_plugin_window_state::StateFlags;
|
||||
|
||||
pub const SETTINGS_STORE: &str = "opencode.settings.dat";
|
||||
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 fn window_state_flags() -> StateFlags {
|
||||
|
||||
@@ -52,6 +52,13 @@ enum InitStep {
|
||||
Done,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum WslPathMode {
|
||||
Windows,
|
||||
Linux,
|
||||
}
|
||||
|
||||
struct InitState {
|
||||
current: watch::Receiver<InitStep>,
|
||||
}
|
||||
@@ -155,211 +162,28 @@ fn check_app_exists(app_name: &str) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_windows_app(app_name: &str) -> bool {
|
||||
resolve_windows_app_path(app_name).is_some()
|
||||
fn check_windows_app(_app_name: &str) -> bool {
|
||||
// Check if command exists in PATH, including .exe
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn resolve_windows_app_path(app_name: &str) -> Option<String> {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn expand_env(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut index = 0;
|
||||
// Try to find the command using 'where'
|
||||
let output = Command::new("where").arg(app_name).output().ok()?;
|
||||
|
||||
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() {
|
||||
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() {
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let direct = Path::new(app_name);
|
||||
if direct.is_absolute() && direct.exists() {
|
||||
return Some(direct.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
let key = app_name
|
||||
.chars()
|
||||
.filter(|v| v.is_ascii_alphanumeric())
|
||||
.flat_map(|v| v.to_lowercase())
|
||||
.collect::<String>();
|
||||
let paths = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let has_ext = |path: &Path, ext: &str| {
|
||||
path.extension()
|
||||
@@ -368,19 +192,22 @@ fn resolve_windows_app_path(app_name: &str) -> Option<String> {
|
||||
.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 bytes = std::fs::read(path).ok()?;
|
||||
let content = String::from_utf8_lossy(&bytes);
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
|
||||
for token in content.split('"') {
|
||||
let Some(exe) = extract_exe(token) else {
|
||||
let lower = token.to_ascii_lowercase();
|
||||
if !lower.contains(".exe") {
|
||||
continue;
|
||||
};
|
||||
}
|
||||
|
||||
let lower = exe.to_ascii_lowercase();
|
||||
if let Some(index) = lower.find("%~dp0") {
|
||||
let base = path.parent()?;
|
||||
let suffix = &exe[index + 5..];
|
||||
let suffix = &token[index + 5..];
|
||||
let mut resolved = PathBuf::from(base);
|
||||
|
||||
for part in suffix.replace('/', "\\").split('\\') {
|
||||
@@ -397,11 +224,9 @@ fn resolve_windows_app_path(app_name: &str) -> Option<String> {
|
||||
if resolved.exists() {
|
||||
return Some(resolved.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved = PathBuf::from(expand_env(&exe));
|
||||
let resolved = PathBuf::from(token);
|
||||
if resolved.exists() {
|
||||
return Some(resolved.to_string_lossy().to_string());
|
||||
}
|
||||
@@ -410,130 +235,74 @@ fn resolve_windows_app_path(app_name: &str) -> Option<String> {
|
||||
None
|
||||
};
|
||||
|
||||
let resolve_where = |query: &str| -> Option<String> {
|
||||
let output = Command::new("where").arg(query).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
for path in &paths {
|
||||
if has_ext(path, "cmd") || has_ext(path, "bat") {
|
||||
if let Some(resolved) = resolve_cmd(path) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if has_ext(path, "cmd") || has_ext(path, "bat") {
|
||||
if let Some(resolved) = resolve_cmd(path) {
|
||||
if path.extension().is_none() {
|
||||
let cmd = path.with_extension("cmd");
|
||||
if cmd.exists() {
|
||||
if let Some(resolved) = resolve_cmd(&cmd) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
if path.extension().is_none() {
|
||||
let cmd = path.with_extension("cmd");
|
||||
if cmd.exists() {
|
||||
if let Some(resolved) = resolve_cmd(&cmd) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
let bat = path.with_extension("bat");
|
||||
if bat.exists() {
|
||||
if let Some(resolved) = resolve_cmd(&bat) {
|
||||
return Some(resolved);
|
||||
}
|
||||
let bat = path.with_extension("bat");
|
||||
if bat.exists() {
|
||||
if let Some(resolved) = resolve_cmd(&bat) {
|
||||
return Some(resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !key.is_empty() {
|
||||
for path in &paths {
|
||||
let dirs = [
|
||||
path.parent(),
|
||||
path.parent().and_then(|dir| dir.parent()),
|
||||
path.parent()
|
||||
.and_then(|dir| dir.parent())
|
||||
.and_then(|dir| dir.parent()),
|
||||
];
|
||||
let key = app_name
|
||||
.chars()
|
||||
.filter(|v| v.is_ascii_alphanumeric())
|
||||
.flat_map(|v| v.to_lowercase())
|
||||
.collect::<String>();
|
||||
|
||||
for dir in dirs.into_iter().flatten() {
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let candidate = entry.path();
|
||||
if !has_ext(&candidate, "exe") {
|
||||
continue;
|
||||
}
|
||||
if !key.is_empty() {
|
||||
for path in &paths {
|
||||
let dirs = [
|
||||
path.parent(),
|
||||
path.parent().and_then(|dir| dir.parent()),
|
||||
path.parent()
|
||||
.and_then(|dir| dir.parent())
|
||||
.and_then(|dir| dir.parent()),
|
||||
];
|
||||
|
||||
let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
for dir in dirs.into_iter().flatten() {
|
||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let candidate = entry.path();
|
||||
if !has_ext(&candidate, "exe") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = stem
|
||||
.chars()
|
||||
.filter(|v| v.is_ascii_alphanumeric())
|
||||
.flat_map(|v| v.to_lowercase())
|
||||
.collect::<String>();
|
||||
let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if name.contains(&key) || key.contains(&name) {
|
||||
return Some(candidate.to_string_lossy().to_string());
|
||||
}
|
||||
let name = stem
|
||||
.chars()
|
||||
.filter(|v| v.is_ascii_alphanumeric())
|
||||
.flat_map(|v| v.to_lowercase())
|
||||
.collect::<String>();
|
||||
|
||||
if name.contains(&key) || key.contains(&name) {
|
||||
return Some(candidate.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
|
||||
paths.first().map(|path| path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -620,32 +389,50 @@ fn check_linux_app(app_name: &str) -> bool {
|
||||
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)]
|
||||
pub fn run() {
|
||||
let builder = 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,
|
||||
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);
|
||||
let builder = make_specta_builder();
|
||||
|
||||
#[cfg(debug_assertions)] // <- Only export on non-release builds
|
||||
builder
|
||||
.export(
|
||||
specta_typescript::Typescript::default(),
|
||||
"../src/bindings.ts",
|
||||
)
|
||||
.expect("Failed to export typescript bindings");
|
||||
export_types(&builder);
|
||||
|
||||
#[cfg(all(target_os = "macos", not(debug_assertions)))]
|
||||
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)]
|
||||
struct LoadingWindowComplete;
|
||||
|
||||
|
||||
@@ -8,9 +8,20 @@ use tokio::task::JoinHandle;
|
||||
|
||||
use crate::{
|
||||
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]
|
||||
#[specta::specta]
|
||||
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(())
|
||||
}
|
||||
|
||||
#[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> {
|
||||
if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
|
||||
tracing::info!(%url, "Using desktop-specific custom URL");
|
||||
|
||||
@@ -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 tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||
use tauri_plugin_window_state::AppHandleExt;
|
||||
@@ -22,6 +25,11 @@ impl MainWindow {
|
||||
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(
|
||||
WebviewWindowBuilder::new(app, Self::LABEL, WebviewUrl::App("/".into())),
|
||||
app,
|
||||
@@ -36,6 +44,7 @@ impl MainWindow {
|
||||
r#"
|
||||
window.__OPENCODE__ ??= {{}};
|
||||
window.__OPENCODE__.updaterEnabled = {UPDATER_ENABLED};
|
||||
window.__OPENCODE__.wsl = {wsl_enabled};
|
||||
"#
|
||||
));
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@ export const commands = {
|
||||
awaitInitialization: (events: Channel) => __TAURI_INVOKE<ServerReadyData>("await_initialization", { events }),
|
||||
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_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"),
|
||||
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
|
||||
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
|
||||
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 }),
|
||||
};
|
||||
|
||||
@@ -34,6 +37,12 @@ export type ServerReadyData = {
|
||||
password: string | null,
|
||||
};
|
||||
|
||||
export type WslConfig = {
|
||||
enabled: boolean,
|
||||
};
|
||||
|
||||
export type WslPathMode = "windows" | "linux";
|
||||
|
||||
/* Tauri Specta runtime */
|
||||
function makeEvent<T>(name: string) {
|
||||
const base = {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { open as shellOpen } from "@tauri-apps/plugin-shell"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
import { check, Update } from "@tauri-apps/plugin-updater"
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
import { AsyncStorage } from "@solid-primitives/storage"
|
||||
@@ -30,7 +29,7 @@ import { UPDATER_ENABLED } from "./updater"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import pkg from "../package.json"
|
||||
import "./styles.css"
|
||||
import { commands, InitStep } from "./bindings"
|
||||
import { commands, InitStep, type WslConfig } from "./bindings"
|
||||
import { Channel } from "@tauri-apps/api/core"
|
||||
import { createMenu } from "./menu"
|
||||
|
||||
@@ -59,338 +58,374 @@ const listenForDeepLinks = async () => {
|
||||
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
|
||||
}
|
||||
|
||||
const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
platform: "desktop",
|
||||
os: (() => {
|
||||
const createPlatform = (password: Accessor<string | null>): Platform => {
|
||||
const os = (() => {
|
||||
const type = ostype()
|
||||
if (type === "macos" || type === "windows" || type === "linux") return type
|
||||
return undefined
|
||||
})(),
|
||||
version: pkg.version,
|
||||
})()
|
||||
|
||||
async openDirectoryPickerDialog(opts) {
|
||||
const result = await open({
|
||||
directory: true,
|
||||
multiple: opts?.multiple ?? false,
|
||||
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
|
||||
})
|
||||
return result
|
||||
},
|
||||
const wslHome = async () => {
|
||||
if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined
|
||||
return commands.wslPath("~", "windows").catch(() => undefined)
|
||||
}
|
||||
|
||||
async openFilePickerDialog(opts) {
|
||||
const result = await open({
|
||||
directory: false,
|
||||
multiple: opts?.multiple ?? false,
|
||||
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
async saveFilePickerDialog(opts) {
|
||||
const result = await save({
|
||||
title: opts?.title ?? t("desktop.dialog.saveFile"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
openLink(url: string) {
|
||||
void shellOpen(url).catch(() => undefined)
|
||||
},
|
||||
|
||||
async openPath(path: string, app?: string) {
|
||||
const os = ostype()
|
||||
if (os === "windows" && app) {
|
||||
const resolvedApp = await commands.resolveAppPath(app)
|
||||
return openerOpenPath(path, resolvedApp || app)
|
||||
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 openerOpenPath(path, app)
|
||||
},
|
||||
return commands.wslPath(result, "linux").catch(() => result) as any
|
||||
}
|
||||
|
||||
back() {
|
||||
window.history.back()
|
||||
},
|
||||
return {
|
||||
platform: "desktop",
|
||||
os,
|
||||
version: pkg.version,
|
||||
|
||||
forward() {
|
||||
window.history.forward()
|
||||
},
|
||||
async openDirectoryPickerDialog(opts) {
|
||||
const defaultPath = await wslHome()
|
||||
const result = await open({
|
||||
directory: true,
|
||||
multiple: opts?.multiple ?? false,
|
||||
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
|
||||
defaultPath,
|
||||
})
|
||||
return await handleWslPicker(result)
|
||||
},
|
||||
|
||||
storage: (() => {
|
||||
type StoreLike = {
|
||||
get(key: string): Promise<string | null | undefined>
|
||||
set(key: string, value: string): Promise<unknown>
|
||||
delete(key: string): Promise<unknown>
|
||||
clear(): Promise<unknown>
|
||||
keys(): Promise<string[]>
|
||||
length(): Promise<number>
|
||||
}
|
||||
async openFilePickerDialog(opts) {
|
||||
const result = await open({
|
||||
directory: false,
|
||||
multiple: opts?.multiple ?? false,
|
||||
title: opts?.title ?? t("desktop.dialog.chooseFile"),
|
||||
})
|
||||
return handleWslPicker(result)
|
||||
},
|
||||
|
||||
const WRITE_DEBOUNCE_MS = 250
|
||||
async saveFilePickerDialog(opts) {
|
||||
const result = await save({
|
||||
title: opts?.title ?? t("desktop.dialog.saveFile"),
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
return handleWslPicker(result)
|
||||
},
|
||||
|
||||
const storeCache = new Map<string, Promise<StoreLike>>()
|
||||
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
|
||||
const memoryCache = new Map<string, StoreLike>()
|
||||
openLink(url: string) {
|
||||
void shellOpen(url).catch(() => undefined)
|
||||
},
|
||||
async openPath(path: string, app?: string) {
|
||||
const os = ostype()
|
||||
if (os === "windows") {
|
||||
const resolvedApp = (app && (await commands.resolveAppPath(app))) || app
|
||||
const resolvedPath = await (async () => {
|
||||
if (window.__OPENCODE__?.wsl) {
|
||||
const converted = await commands.wslPath(path, "windows").catch(() => null)
|
||||
if (converted) return converted
|
||||
}
|
||||
|
||||
const flushAll = async () => {
|
||||
const apis = Array.from(apiCache.values())
|
||||
await Promise.all(apis.map((api) => api.flush().catch(() => undefined)))
|
||||
}
|
||||
return path
|
||||
})()
|
||||
return openerOpenPath(resolvedPath, resolvedApp)
|
||||
}
|
||||
return openerOpenPath(path, app)
|
||||
},
|
||||
|
||||
if ("addEventListener" in globalThis) {
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
void flushAll()
|
||||
back() {
|
||||
window.history.back()
|
||||
},
|
||||
|
||||
forward() {
|
||||
window.history.forward()
|
||||
},
|
||||
|
||||
storage: (() => {
|
||||
type StoreLike = {
|
||||
get(key: string): Promise<string | null | undefined>
|
||||
set(key: string, value: string): Promise<unknown>
|
||||
delete(key: string): Promise<unknown>
|
||||
clear(): Promise<unknown>
|
||||
keys(): Promise<string[]>
|
||||
length(): Promise<number>
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", () => void flushAll())
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
}
|
||||
const WRITE_DEBOUNCE_MS = 250
|
||||
|
||||
const createMemoryStore = () => {
|
||||
const data = new Map<string, string>()
|
||||
const store: StoreLike = {
|
||||
get: async (key) => data.get(key),
|
||||
set: async (key, value) => {
|
||||
data.set(key, value)
|
||||
},
|
||||
delete: async (key) => {
|
||||
data.delete(key)
|
||||
},
|
||||
clear: async () => {
|
||||
data.clear()
|
||||
},
|
||||
keys: async () => Array.from(data.keys()),
|
||||
length: async () => data.size,
|
||||
const storeCache = new Map<string, Promise<StoreLike>>()
|
||||
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
|
||||
const memoryCache = new Map<string, StoreLike>()
|
||||
|
||||
const flushAll = async () => {
|
||||
const apis = Array.from(apiCache.values())
|
||||
await Promise.all(apis.map((api) => api.flush().catch(() => undefined)))
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
const getStore = (name: string) => {
|
||||
const cached = storeCache.get(name)
|
||||
if (cached) return cached
|
||||
if ("addEventListener" in globalThis) {
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
void flushAll()
|
||||
}
|
||||
|
||||
const store = Store.load(name).catch(() => {
|
||||
const cached = memoryCache.get(name)
|
||||
window.addEventListener("pagehide", () => void flushAll())
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
}
|
||||
|
||||
const createMemoryStore = () => {
|
||||
const data = new Map<string, string>()
|
||||
const store: StoreLike = {
|
||||
get: async (key) => data.get(key),
|
||||
set: async (key, value) => {
|
||||
data.set(key, value)
|
||||
},
|
||||
delete: async (key) => {
|
||||
data.delete(key)
|
||||
},
|
||||
clear: async () => {
|
||||
data.clear()
|
||||
},
|
||||
keys: async () => Array.from(data.keys()),
|
||||
length: async () => data.size,
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
const getStore = (name: string) => {
|
||||
const cached = storeCache.get(name)
|
||||
if (cached) return cached
|
||||
|
||||
const memory = createMemoryStore()
|
||||
memoryCache.set(name, memory)
|
||||
return memory
|
||||
})
|
||||
const store = Store.load(name).catch(() => {
|
||||
const cached = memoryCache.get(name)
|
||||
if (cached) return cached
|
||||
|
||||
storeCache.set(name, store)
|
||||
return store
|
||||
}
|
||||
const memory = createMemoryStore()
|
||||
memoryCache.set(name, memory)
|
||||
return memory
|
||||
})
|
||||
|
||||
const createStorage = (name: string) => {
|
||||
const pending = new Map<string, string | null>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let flushing: Promise<void> | undefined
|
||||
storeCache.set(name, store)
|
||||
return store
|
||||
}
|
||||
|
||||
const flush = async () => {
|
||||
if (flushing) return flushing
|
||||
const createStorage = (name: string) => {
|
||||
const pending = new Map<string, string | null>()
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let flushing: Promise<void> | undefined
|
||||
|
||||
flushing = (async () => {
|
||||
const store = await getStore(name)
|
||||
while (pending.size > 0) {
|
||||
const batch = Array.from(pending.entries())
|
||||
pending.clear()
|
||||
for (const [key, value] of batch) {
|
||||
if (value === null) {
|
||||
await store.delete(key).catch(() => undefined)
|
||||
} else {
|
||||
await store.set(key, value).catch(() => undefined)
|
||||
const flush = async () => {
|
||||
if (flushing) return flushing
|
||||
|
||||
flushing = (async () => {
|
||||
const store = await getStore(name)
|
||||
while (pending.size > 0) {
|
||||
const batch = Array.from(pending.entries())
|
||||
pending.clear()
|
||||
for (const [key, value] of batch) {
|
||||
if (value === null) {
|
||||
await store.delete(key).catch(() => undefined)
|
||||
} else {
|
||||
await store.set(key, value).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})().finally(() => {
|
||||
flushing = undefined
|
||||
})
|
||||
})().finally(() => {
|
||||
flushing = undefined
|
||||
})
|
||||
|
||||
return flushing
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (timer) return
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined
|
||||
void flush()
|
||||
}, WRITE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
const api: AsyncStorage & { flush: () => Promise<void> } = {
|
||||
flush,
|
||||
getItem: async (key: string) => {
|
||||
const next = pending.get(key)
|
||||
if (next !== undefined) return next
|
||||
|
||||
const store = await getStore(name)
|
||||
const value = await store.get(key).catch(() => null)
|
||||
if (value === undefined) return null
|
||||
return value
|
||||
},
|
||||
setItem: async (key: string, value: string) => {
|
||||
pending.set(key, value)
|
||||
schedule()
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
pending.set(key, null)
|
||||
schedule()
|
||||
},
|
||||
clear: async () => {
|
||||
pending.clear()
|
||||
const store = await getStore(name)
|
||||
await store.clear().catch(() => undefined)
|
||||
},
|
||||
key: async (index: number) => {
|
||||
const store = await getStore(name)
|
||||
return (await store.keys().catch(() => []))[index]
|
||||
},
|
||||
getLength: async () => {
|
||||
const store = await getStore(name)
|
||||
return await store.length().catch(() => 0)
|
||||
},
|
||||
get length() {
|
||||
return api.getLength()
|
||||
},
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
return (name = "default.dat") => {
|
||||
const cached = apiCache.get(name)
|
||||
if (cached) return cached
|
||||
|
||||
const api = createStorage(name)
|
||||
apiCache.set(name, api)
|
||||
return api
|
||||
}
|
||||
})(),
|
||||
|
||||
checkUpdate: async () => {
|
||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
||||
const next = await check().catch(() => null)
|
||||
if (!next) return { updateAvailable: false }
|
||||
const ok = await next
|
||||
.download()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!ok) return { updateAvailable: false }
|
||||
update = next
|
||||
return { updateAvailable: true, version: next.version }
|
||||
},
|
||||
|
||||
update: async () => {
|
||||
if (!UPDATER_ENABLED || !update) return
|
||||
if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
|
||||
await update.install().catch(() => undefined)
|
||||
},
|
||||
|
||||
restart: async () => {
|
||||
await commands.killSidecar().catch(() => undefined)
|
||||
await relaunch()
|
||||
},
|
||||
|
||||
notify: async (title, description, href) => {
|
||||
const granted = await isPermissionGranted().catch(() => false)
|
||||
const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
|
||||
if (permission !== "granted") return
|
||||
|
||||
const win = getCurrentWindow()
|
||||
const focused = await win.isFocused().catch(() => document.hasFocus())
|
||||
if (focused) return
|
||||
|
||||
await Promise.resolve()
|
||||
.then(() => {
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96-v3.png",
|
||||
})
|
||||
notification.onclick = () => {
|
||||
const win = getCurrentWindow()
|
||||
void win.show().catch(() => undefined)
|
||||
void win.unminimize().catch(() => undefined)
|
||||
void win.setFocus().catch(() => undefined)
|
||||
if (href) {
|
||||
window.history.pushState(null, "", href)
|
||||
window.dispatchEvent(new PopStateEvent("popstate"))
|
||||
}
|
||||
notification.close()
|
||||
return flushing
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (timer) return
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined
|
||||
void flush()
|
||||
}, WRITE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
const api: AsyncStorage & { flush: () => Promise<void> } = {
|
||||
flush,
|
||||
getItem: async (key: string) => {
|
||||
const next = pending.get(key)
|
||||
if (next !== undefined) return next
|
||||
|
||||
const store = await getStore(name)
|
||||
const value = await store.get(key).catch(() => null)
|
||||
if (value === undefined) return null
|
||||
return value
|
||||
},
|
||||
setItem: async (key: string, value: string) => {
|
||||
pending.set(key, value)
|
||||
schedule()
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
pending.set(key, null)
|
||||
schedule()
|
||||
},
|
||||
clear: async () => {
|
||||
pending.clear()
|
||||
const store = await getStore(name)
|
||||
await store.clear().catch(() => undefined)
|
||||
},
|
||||
key: async (index: number) => {
|
||||
const store = await getStore(name)
|
||||
return (await store.keys().catch(() => []))[index]
|
||||
},
|
||||
getLength: async () => {
|
||||
const store = await getStore(name)
|
||||
return await store.length().catch(() => 0)
|
||||
},
|
||||
get length() {
|
||||
return api.getLength()
|
||||
},
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
return (name = "default.dat") => {
|
||||
const cached = apiCache.get(name)
|
||||
if (cached) return cached
|
||||
|
||||
const api = createStorage(name)
|
||||
apiCache.set(name, api)
|
||||
return api
|
||||
}
|
||||
})(),
|
||||
|
||||
checkUpdate: async () => {
|
||||
if (!UPDATER_ENABLED) return { updateAvailable: false }
|
||||
const next = await check().catch(() => null)
|
||||
if (!next) return { updateAvailable: false }
|
||||
const ok = await next
|
||||
.download()
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!ok) return { updateAvailable: false }
|
||||
update = next
|
||||
return { updateAvailable: true, version: next.version }
|
||||
},
|
||||
|
||||
update: async () => {
|
||||
if (!UPDATER_ENABLED || !update) return
|
||||
if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
|
||||
await update.install().catch(() => undefined)
|
||||
},
|
||||
|
||||
restart: async () => {
|
||||
await commands.killSidecar().catch(() => undefined)
|
||||
await relaunch()
|
||||
},
|
||||
|
||||
notify: async (title, description, href) => {
|
||||
const granted = await isPermissionGranted().catch(() => false)
|
||||
const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
|
||||
if (permission !== "granted") return
|
||||
|
||||
const win = getCurrentWindow()
|
||||
const focused = await win.isFocused().catch(() => document.hasFocus())
|
||||
if (focused) return
|
||||
|
||||
await Promise.resolve()
|
||||
.then(() => {
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96-v3.png",
|
||||
})
|
||||
notification.onclick = () => {
|
||||
const win = getCurrentWindow()
|
||||
void win.show().catch(() => undefined)
|
||||
void win.unminimize().catch(() => undefined)
|
||||
void win.setFocus().catch(() => undefined)
|
||||
if (href) {
|
||||
window.history.pushState(null, "", href)
|
||||
window.dispatchEvent(new PopStateEvent("popstate"))
|
||||
}
|
||||
notification.close()
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
|
||||
fetch: (input, init) => {
|
||||
const pw = password()
|
||||
|
||||
const addHeader = (headers: Headers, password: string) => {
|
||||
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
|
||||
}
|
||||
|
||||
if (input instanceof Request) {
|
||||
if (pw) addHeader(input.headers, pw)
|
||||
return tauriFetch(input)
|
||||
} else {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (pw) addHeader(headers, pw)
|
||||
return tauriFetch(input, {
|
||||
...(init as any),
|
||||
headers: headers,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
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 () => {
|
||||
const result = await commands.getDefaultServerUrl().catch(() => null)
|
||||
return result
|
||||
},
|
||||
|
||||
setDefaultServerUrl: async (url: string | null) => {
|
||||
await commands.setDefaultServerUrl(url)
|
||||
},
|
||||
|
||||
getDisplayBackend: async () => {
|
||||
const result = await commands.getDisplayBackend().catch(() => null)
|
||||
return result
|
||||
},
|
||||
|
||||
setDisplayBackend: async (backend) => {
|
||||
await commands.setDisplayBackend(backend)
|
||||
},
|
||||
|
||||
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
|
||||
|
||||
webviewZoom,
|
||||
|
||||
checkAppExists: async (appName: string) => {
|
||||
return commands.checkAppExists(appName)
|
||||
},
|
||||
|
||||
async readClipboardImage() {
|
||||
const image = await readImage().catch(() => null)
|
||||
if (!image) return null
|
||||
const bytes = await image.rgba().catch(() => null)
|
||||
if (!bytes || bytes.length === 0) return null
|
||||
const size = await image.size().catch(() => null)
|
||||
if (!size) return null
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = size.width
|
||||
canvas.height = size.height
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return null
|
||||
const imageData = ctx.createImageData(size.width, size.height)
|
||||
imageData.data.set(bytes)
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
return new Promise<File | null>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return resolve(null)
|
||||
resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" }))
|
||||
}, "image/png")
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
|
||||
fetch: (input, init) => {
|
||||
const pw = password()
|
||||
|
||||
const addHeader = (headers: Headers, password: string) => {
|
||||
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
|
||||
}
|
||||
|
||||
if (input instanceof Request) {
|
||||
if (pw) addHeader(input.headers, pw)
|
||||
return tauriFetch(input)
|
||||
} else {
|
||||
const headers = new Headers(init?.headers)
|
||||
if (pw) addHeader(headers, pw)
|
||||
return tauriFetch(input, {
|
||||
...(init as any),
|
||||
headers: headers,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
getDefaultServerUrl: async () => {
|
||||
const result = await commands.getDefaultServerUrl().catch(() => null)
|
||||
return result
|
||||
},
|
||||
|
||||
setDefaultServerUrl: async (url: string | null) => {
|
||||
await commands.setDefaultServerUrl(url)
|
||||
},
|
||||
|
||||
getDisplayBackend: async () => {
|
||||
const result = await invoke<DisplayBackend | null>("get_display_backend").catch(() => null)
|
||||
return result
|
||||
},
|
||||
|
||||
setDisplayBackend: async (backend) => {
|
||||
await invoke("set_display_backend", { backend }).catch(() => undefined)
|
||||
},
|
||||
|
||||
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
|
||||
|
||||
webviewZoom,
|
||||
|
||||
checkAppExists: async (appName: string) => {
|
||||
return commands.checkAppExists(appName)
|
||||
},
|
||||
|
||||
async readClipboardImage() {
|
||||
const image = await readImage().catch(() => null)
|
||||
if (!image) return null
|
||||
const bytes = await image.rgba().catch(() => null)
|
||||
if (!bytes || bytes.length === 0) return null
|
||||
const size = await image.size().catch(() => null)
|
||||
if (!size) return null
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = size.width
|
||||
canvas.height = size.height
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return null
|
||||
const imageData = ctx.createImageData(size.width, size.height)
|
||||
imageData.data.set(bytes)
|
||||
ctx.putImageData(imageData, 0, 0)
|
||||
return new Promise<File | null>((resolve) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) return resolve(null)
|
||||
resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" }))
|
||||
}, "image/png")
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let menuTrigger = null as null | ((id: string) => void)
|
||||
createMenu((id) => {
|
||||
@@ -400,6 +435,7 @@ void listenForDeepLinks()
|
||||
|
||||
render(() => {
|
||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||
|
||||
const platform = createPlatform(() => serverPassword())
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
|
||||
Reference in New Issue
Block a user