Compare commits

...

2 Commits

Author SHA1 Message Date
Brendan Allan
bef9938c0a bump specta 2026-01-30 09:11:13 +08:00
Brendan Allan
f69ea40e55 start on specta integration 2026-01-29 16:36:51 +08:00
14 changed files with 912 additions and 630 deletions

View File

@@ -30,7 +30,7 @@ import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error"
import { Suspense } from "solid-js"
import { Suspense, JSX } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) {
)
}
export function AppInterface(props: { defaultUrl?: string }) {
export function AppInterface(props: { defaultUrl?: string; root?: (props: ParentProps) => JSX.Element }) {
const platform = usePlatform()
const stored = (() => {
@@ -105,13 +105,15 @@ export function AppInterface(props: { defaultUrl?: string }) {
return window.location.origin
}
console.log("interface")
return (
<ServerProvider defaultUrl={defaultServerUrl()}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(props) => (
root={(rootProps) => (
<SettingsProvider>
<PermissionProvider>
<LayoutProvider>
@@ -119,7 +121,7 @@ export function AppInterface(props: { defaultUrl?: string }) {
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>{props.children}</Layout>
<Layout>{rootProps.children}</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>

View File

@@ -1079,6 +1079,9 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
createEffect(() => {
console.log({ ...value })
})
return (
<Switch>
<Match when={value.ready}>

File diff suppressed because it is too large Load Diff

View File

@@ -43,10 +43,14 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
uuid = { version = "1.19.0", features = ["v4"] }
tauri-plugin-decorum = "1.1.1"
comrak = { version = "0.50", default-features = false }
url = "2"
specta = "=2.0.0-rc.22"
specta-typescript = "0.0.9"
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
webkit2gtk = "=2.0.1"
webkit2gtk = "=2.0.2"
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
@@ -59,3 +63,10 @@ windows = { version = "0.61", features = [
"Win32_System_Threading",
"Win32_Security"
] }
[patch.crates-io]
specta = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
tauri-specta = { git = "https://github.com/specta-rs/tauri-specta", rev = "52c122a2110385857cbfc5a970016fa424007170" }
# TODO: https://github.com/tauri-apps/tauri/pull/14812
# tauri = { git = "https://github.com/tauri-apps/tauri", rev = "4d5d78daf636feaac20c5bc48a6071491c4291ee" }

View File

@@ -51,6 +51,7 @@ fn is_cli_installed() -> bool {
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
#[tauri::command]
#[specta::specta]
pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
if cfg!(not(unix)) {
return Err("CLI installation is only supported on macOS & Linux".to_string());

View File

@@ -0,0 +1,28 @@
use std::collections::BTreeMap;
#[derive(serde::Serialize, Clone, tauri_specta::Event, specta::Type)]
pub enum DeepLinkAction {
OpenProject {
directory: String,
session: Option<String>,
},
}
impl DeepLinkAction {
pub fn from_url(url: url::Url) -> Option<Self> {
if url.scheme() != "opencode" {
return None;
}
let action = url.path().trim_start_matches('/');
let mut query_pairs: BTreeMap<_, _> = url.query_pairs().collect();
match action {
"open-project" => Some(DeepLinkAction::OpenProject {
directory: query_pairs.remove("directory")?.to_string(),
session: query_pairs.remove("session").map(|v| v.to_string()),
}),
_ => None,
}
}
}

View File

@@ -1,36 +1,42 @@
mod cli;
mod deep_link;
#[cfg(windows)]
mod job_object;
mod markdown;
mod window_customizer;
use cli::{install_cli, sync_cli};
use futures::FutureExt;
use futures::channel::mpsc;
use futures::future;
use futures::{FutureExt, StreamExt};
#[cfg(windows)]
use job_object::*;
use specta_typescript::Typescript;
use std::{
collections::VecDeque,
net::TcpListener,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder};
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
use tauri_plugin_deep_link::DeepLinkExt;
use tauri::{AppHandle, Emitter, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder};
#[cfg(windows)]
use tauri_plugin_decorum::WebviewWindowExt;
use tauri_plugin_deep_link::DeepLinkExt;
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_store::StoreExt;
use tauri_specta::{Builder, Event, collect_commands, collect_events};
use tokio::sync::oneshot;
use crate::deep_link::DeepLinkAction;
use crate::window_customizer::PinchZoomDisablePlugin;
const SETTINGS_STORE: &str = "opencode.settings.dat";
const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
#[derive(Clone, serde::Serialize)]
#[derive(Clone, serde::Serialize, specta::Type)]
struct ServerReadyData {
url: String,
password: Option<String>,
@@ -64,6 +70,7 @@ struct LogState(Arc<Mutex<VecDeque<String>>>);
const MAX_LOG_ENTRIES: usize = 200;
#[tauri::command]
#[specta::specta]
fn kill_sidecar(app: AppHandle) {
let Some(server_state) = app.try_state::<ServerState>() else {
println!("Server not running");
@@ -97,6 +104,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
}
#[tauri::command]
#[specta::specta]
async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
state
.status
@@ -106,6 +114,7 @@ async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerRead
}
#[tauri::command]
#[specta::specta]
fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
let store = app
.store(SETTINGS_STORE)
@@ -119,6 +128,7 @@ fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
}
#[tauri::command]
#[specta::specta]
async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
let store = app
.store(SETTINGS_STORE)
@@ -248,6 +258,20 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
.unwrap_or(false)
}
struct AppState {
ready_tx: tokio::sync::Mutex<Option<oneshot::Sender<()>>>,
}
#[tauri::command]
#[specta::specta]
async fn notify_ready(state: tauri::State<'_, AppState>) -> Result<(), ()> {
if let Some(ready_tx) = state.ready_tx.lock().await.take() {
let _ = ready_tx.send(());
}
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
@@ -257,6 +281,24 @@ pub fn run() {
.arg("opencode-cli")
.output();
let builder = Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(collect_commands![
kill_sidecar,
install_cli,
ensure_server_ready,
get_default_server_url,
set_default_server_url,
markdown::parse_markdown_command,
notify_ready
])
.events(collect_events![DeepLinkAction]);
#[cfg(debug_assertions)] // <- Only export on non-release builds
builder
.export(Typescript::default(), "../src/bindings.ts")
.expect("Failed to export typescript bindings");
let mut builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
// Focus existing window when another instance is launched
@@ -285,15 +327,11 @@ pub fn run() {
.plugin(tauri_plugin_notification::init())
.plugin(PinchZoomDisablePlugin)
.plugin(tauri_plugin_decorum::init())
.invoke_handler(tauri::generate_handler![
kill_sidecar,
install_cli,
ensure_server_ready,
get_default_server_url,
set_default_server_url,
markdown::parse_markdown_command
])
.invoke_handler(builder.invoke_handler())
.setup(move |app| {
// This is also required if you want to use events
builder.mount_events(app);
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
app.deep_link().register_all().ok();
@@ -305,6 +343,11 @@ pub fn run() {
#[cfg(windows)]
app.manage(JobObjectState::new());
let (ready_tx, ready_rx) = oneshot::channel::<()>();
app.manage(AppState {
ready_tx: tokio::sync::Mutex::new(Some(ready_tx))
});
let primary_monitor = app.primary_monitor().ok().flatten();
let size = primary_monitor
.map(|m| m.size().to_logical(m.scale_factor()))
@@ -344,14 +387,26 @@ pub fn run() {
)
.decorations(false);
let window = window_builder.build().expect("Failed to create window");
let _window = window_builder.build().expect("Failed to create window");
#[cfg(windows)]
let _ = window.create_overlay_titlebar();
let _ = _window.create_overlay_titlebar();
let (tx, rx) = oneshot::channel();
app.manage(ServerState::new(None, rx));
let (deeplink_tx, deeplink_rx) = mpsc::unbounded();
for url in app.deep_link().get_current().ok().flatten().unwrap_or_default() {
let _ = deeplink_tx.unbounded_send(url);
}
app.deep_link().on_open_url(move |e| {
for url in e.urls() {
let _ = deeplink_tx.unbounded_send(url);
}
});
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
@@ -398,6 +453,25 @@ pub fn run() {
});
}
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
let Ok(_) = ready_rx.await else {
return
};
tauri::async_runtime::spawn(deeplink_rx.for_each(move |url| {
if let Some(action) = DeepLinkAction::from_url(url)
&& let Some(window) = app.get_webview_window("main") {
let _ = action.emit(&window);
}
future::ready(())
}));
});
}
Ok(())
});

View File

@@ -1,4 +1,6 @@
use comrak::{create_formatter, parse_document, Arena, Options, html::ChildRendering, nodes::NodeValue};
use comrak::{
Arena, Options, create_formatter, html::ChildRendering, nodes::NodeValue, parse_document,
};
use std::fmt::Write;
create_formatter!(ExternalLinkFormatter, {
@@ -55,6 +57,7 @@ pub fn parse_markdown(input: &str) -> String {
}
#[tauri::command]
#[specta::specta]
pub async fn parse_markdown_command(markdown: String) -> Result<String, String> {
Ok(parse_markdown(&markdown))
}

View File

@@ -1,4 +1,4 @@
use tauri::{plugin::Plugin, Manager, Runtime, Window};
use tauri::{Manager, Runtime, Window, plugin::Plugin};
pub struct PinchZoomDisablePlugin;
@@ -21,8 +21,8 @@ impl<R: Runtime> Plugin<R> for PinchZoomDisablePlugin {
let _ = webview_window.with_webview(|_webview| {
#[cfg(target_os = "linux")]
unsafe {
use gtk::glib::ObjectExt;
use gtk::GestureZoom;
use gtk::glib::ObjectExt;
use webkit2gtk::glib::gobject_ffi;
if let Some(data) = _webview.inner().data::<GestureZoom>("wk-view-zoom-gesture") {

View File

@@ -0,0 +1,62 @@
// This file has been generated by Tauri Specta. Do not edit this file manually.
import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core"
import * as __TAURI_EVENT from "@tauri-apps/api/event"
/** Commands */
export const commands = {
killSidecar: () => __TAURI_INVOKE<void>("kill_sidecar"),
installCli: () => typedError<string, string>(__TAURI_INVOKE("install_cli")),
ensureServerReady: () => typedError<ServerReadyData, string>(__TAURI_INVOKE("ensure_server_ready")),
getDefaultServerUrl: () => typedError<string | null, string>(__TAURI_INVOKE("get_default_server_url")),
setDefaultServerUrl: (url: string | null) =>
typedError<null, string>(__TAURI_INVOKE("set_default_server_url", { url })),
parseMarkdownCommand: (markdown: string) =>
typedError<string, string>(__TAURI_INVOKE("parse_markdown_command", { markdown })),
notifyReady: () => typedError<null, null>(__TAURI_INVOKE("notify_ready")),
}
/** Events */
export const events = {
deepLinkAction: makeEvent<DeepLinkAction>("deep-link-action"),
}
/* Types */
export type DeepLinkAction = { OpenProject: { directory: string; session: string | null } }
export type ServerReadyData = {
url: string
password: string | null
}
/* Tauri Specta runtime */
async function typedError<T, E>(
result: Promise<T>,
): Promise<{ status: "ok"; data: T } | { status: "error"; error: E }> {
try {
return { status: "ok", data: await result }
} catch (e) {
if (e instanceof Error) throw e
return { status: "error", error: e as any }
}
}
function makeEvent<T>(name: string) {
const base = {
listen: (cb: __TAURI_EVENT.EventCallback<T>) => __TAURI_EVENT.listen(name, cb),
once: (cb: __TAURI_EVENT.EventCallback<T>) => __TAURI_EVENT.once(name, cb),
emit: (payload: T) =>
__TAURI_EVENT.emit(name, payload) as unknown as T extends null
? () => Promise<void>
: (payload: T) => Promise<void>,
}
const fn = (target: import("@tauri-apps/api/webview").Webview | import("@tauri-apps/api/window").Window) => ({
listen: (cb: __TAURI_EVENT.EventCallback<T>) => target.listen(name, cb),
once: (cb: __TAURI_EVENT.EventCallback<T>) => target.once(name, cb),
emit: (payload: T) =>
target.emit(name, payload) as unknown as T extends null ? () => Promise<void> : (payload: T) => Promise<void>,
})
return Object.assign(fn, base)
}

View File

@@ -1,15 +1,17 @@
import { invoke } from "@tauri-apps/api/core"
import { message } from "@tauri-apps/plugin-dialog"
import { initI18n, t } from "./i18n"
import { commands } from "./bindings"
export async function installCli(): Promise<void> {
await initI18n()
try {
const path = await invoke<string>("install_cli")
await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") })
} catch (e) {
await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") })
const res = await commands.installCli()
if (res.status === "ok") {
await message(t("desktop.cli.installed.message", { path: res.data }), { title: t("desktop.cli.installed.title") })
} else {
await message(t("desktop.cli.failed.message", { error: String(res.error) }), {
title: t("desktop.cli.failed.title"),
})
}
}

View File

@@ -3,11 +3,9 @@ import "./webview-zoom"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
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 { invoke } from "@tauri-apps/api/core"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process"
@@ -22,6 +20,7 @@ import { createMenu } from "./menu"
import { initI18n, t } from "./i18n"
import pkg from "../package.json"
import "./styles.css"
import { commands, events } from "./bindings"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -43,21 +42,21 @@ window.getComputedStyle = ((elt: Element, pseudoElt?: string | null) => {
let update: Update | null = null
const deepLinkEvent = "opencode:deep-link"
// const deepLinkEvent = "opencode:deep-link"
const emitDeepLinks = (urls: string[]) => {
if (urls.length === 0) return
window.__OPENCODE__ ??= {}
const pending = window.__OPENCODE__.deepLinks ?? []
window.__OPENCODE__.deepLinks = [...pending, ...urls]
window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
}
// const emitDeepLinks = (urls: string[]) => {
// if (urls.length === 0) return
// window.__OPENCODE__ ??= {}
// const pending = window.__OPENCODE__.deepLinks ?? []
// window.__OPENCODE__.deepLinks = [...pending, ...urls]
// window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } }))
// }
const listenForDeepLinks = async () => {
const startUrls = await getCurrent().catch(() => null)
if (startUrls?.length) emitDeepLinks(startUrls)
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
}
// const listenForDeepLinks = async () => {
// const startUrls = await getCurrent().catch(() => null)
// if (startUrls?.length) emitDeepLinks(startUrls)
// await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
// }
const createPlatform = (password: Accessor<string | null>): Platform => ({
platform: "desktop",
@@ -274,12 +273,12 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
update: async () => {
if (!UPDATER_ENABLED || !update) return
if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined)
if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
await update.install().catch(() => undefined)
},
restart: async () => {
await invoke("kill_sidecar").catch(() => undefined)
await commands.killSidecar().catch(() => undefined)
await relaunch()
},
@@ -334,22 +333,21 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
}
},
getDefaultServerUrl: async () => {
const result = await invoke<string | null>("get_default_server_url").catch(() => null)
return result
},
getDefaultServerUrl: () => commands.getDefaultServerUrl().then((v) => (v.status === "ok" ? v.data : null)),
setDefaultServerUrl: async (url: string | null) => {
await invoke("set_default_server_url", { url })
await commands.setDefaultServerUrl(url)
},
parseMarkdown: async (markdown: string) => {
return invoke<string>("parse_markdown_command", { markdown })
},
parseMarkdown: (markdown) =>
commands.parseMarkdownCommand(markdown).then((v) => {
if (v.status === "ok") return v.data
throw new Error(v.error)
}),
})
createMenu()
void listenForDeepLinks()
// void listenForDeepLinks()
render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
@@ -379,6 +377,14 @@ render(() => {
window.__OPENCODE__ ??= {}
window.__OPENCODE__.serverPassword = data().password ?? undefined
onMount(() => {
commands.notifyReady()
})
events.deepLinkAction.listen((deepLink) => {
console.log({ deepLink })
})
return <AppInterface defaultUrl={data().url} />
}}
</ServerGate>
@@ -392,8 +398,9 @@ type ServerReadyData = { url: string; password: string | null }
// Gate component that waits for the server to be ready
function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
const [serverData] = createResource<ServerReadyData>(() =>
invoke("ensure_server_ready").then((v) => {
return new Promise((res) => setTimeout(() => res(v as ServerReadyData), 2000))
commands.ensureServerReady().then((v) => {
if (v.status === "ok") return v.data
throw new Error(v.error)
}),
)
@@ -406,7 +413,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
}
const restartApp = async () => {
await invoke("kill_sidecar").catch(() => undefined)
await commands.killSidecar().catch(() => undefined)
await relaunch().catch(() => undefined)
}

View File

@@ -1,11 +1,11 @@
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
import { type as ostype } from "@tauri-apps/plugin-os"
import { invoke } from "@tauri-apps/api/core"
import { relaunch } from "@tauri-apps/plugin-process"
import { runUpdater, UPDATER_ENABLED } from "./updater"
import { installCli } from "./cli"
import { initI18n, t } from "./i18n"
import { commands } from "./bindings"
export async function createMenu() {
if (ostype() !== "macos") return
@@ -35,7 +35,7 @@ export async function createMenu() {
}),
await MenuItem.new({
action: async () => {
await invoke("kill_sidecar").catch(() => undefined)
await commands.killSidecar().catch(() => undefined)
await relaunch().catch(() => undefined)
},
text: t("desktop.menu.restart"),

View File

@@ -1,10 +1,10 @@
import { check } from "@tauri-apps/plugin-updater"
import { relaunch } from "@tauri-apps/plugin-process"
import { ask, message } from "@tauri-apps/plugin-dialog"
import { invoke } from "@tauri-apps/api/core"
import { type as ostype } from "@tauri-apps/plugin-os"
import { initI18n, t } from "./i18n"
import { commands } from "./bindings"
export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
@@ -39,13 +39,13 @@ export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
if (!shouldUpdate) return
try {
if (ostype() === "windows") await invoke("kill_sidecar")
if (ostype() === "windows") await commands.killSidecar()
await update.install()
} catch {
await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") })
return
}
await invoke("kill_sidecar")
await commands.killSidecar()
await relaunch()
}