start on specta integration

This commit is contained in:
Brendan Allan
2026-01-29 16:36:51 +08:00
parent 82717f6e8b
commit f69ea40e55
9 changed files with 791 additions and 561 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 = (() => {
@@ -111,7 +111,7 @@ export function AppInterface(props: { defaultUrl?: string }) {
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(props) => (
root={(rootProps) => (
<SettingsProvider>
<PermissionProvider>
<LayoutProvider>
@@ -119,7 +119,7 @@ export function AppInterface(props: { defaultUrl?: string }) {
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>{props.children}</Layout>
<Layout>{props.root?.(rootProps) ?? rootProps.children}</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,10 @@ 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 = { git = "https://github.com/specta-rs/specta", rev = "5409c179845bce2dc4a7315870c83c8d80c0dee4" }
specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "f1e55f8c774a9506e3ca6bec1265ce5dda6831a0" }
tauri-specta = { git = "https://github.com/specta-rs/tauri-specta", rev = "c0cd3c58e29191845e2bd2b30238246469903727", features = ["derive", "typescript"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
@@ -59,3 +63,8 @@ windows = { version = "0.61", features = [
"Win32_System_Threading",
"Win32_Security"
] }
[patch.crates-io]
specta = { git = "https://github.com/specta-rs/specta", rev = "5409c179845bce2dc4a7315870c83c8d80c0dee4" }
specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "f1e55f8c774a9506e3ca6bec1265ce5dda6831a0" }
tauri-specta = { git = "https://github.com/specta-rs/tauri-specta", rev = "c0cd3c58e29191845e2bd2b30238246469903727" }

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::future;
use futures::channel::mpsc;
use futures::{FutureExt, StreamExt};
use futures::{SinkExt, future};
#[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, collect_commands};
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)
@@ -257,6 +267,22 @@ pub fn run() {
.arg("opencode-cli")
.output();
let mut 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
]);
#[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 +311,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();
@@ -344,14 +366,28 @@ 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 (ready_tx, ready_rx) = oneshot::channel::<()>();
{
let app = app.clone();
tauri::async_runtime::spawn(async move {
@@ -398,6 +434,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 _ = window.emit("opencode:deep-link", action);
}
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,123 @@
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
/** user-defined commands **/
export const commands = {
async killSidecar() : Promise<void> {
await TAURI_INVOKE("kill_sidecar");
},
async installCli() : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("install_cli") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async ensureServerReady() : Promise<Result<ServerReadyData, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("ensure_server_ready") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async getDefaultServerUrl() : Promise<Result<string | null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("get_default_server_url") };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async setDefaultServerUrl(url: string | null) : Promise<Result<null, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("set_default_server_url", { url }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
},
async parseMarkdownCommand(markdown: string) : Promise<Result<string, string>> {
try {
return { status: "ok", data: await TAURI_INVOKE("parse_markdown_command", { markdown }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
}
}
/** user-defined events **/
/** user-defined constants **/
/** user-defined types **/
export type ServerReadyData = { url: string; password: string | null }
/** tauri-specta globals **/
import {
invoke as TAURI_INVOKE,
Channel as TAURI_CHANNEL,
} from "@tauri-apps/api/core";
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
type __EventObj__<T> = {
listen: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
once: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: null extends T
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
};
export type Result<T, E> =
| { status: "ok"; data: T }
| { status: "error"; error: E };
function __makeEvents__<T extends Record<string, any>>(
mappings: Record<keyof T, string>,
) {
return new Proxy(
{} as unknown as {
[K in keyof T]: __EventObj__<T[K]> & {
(handle: __WebviewWindow__): __EventObj__<T[K]>;
};
},
{
get: (_, event) => {
const name = mappings[event as keyof T];
return new Proxy((() => {}) as any, {
apply: (_, __, [window]: [__WebviewWindow__]) => ({
listen: (arg: any) => window.listen(name, arg),
once: (arg: any) => window.once(name, arg),
emit: (arg: any) => window.emit(name, arg),
}),
get: (_, command: keyof __EventObj__<any>) => {
switch (command) {
case "listen":
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
case "once":
return (arg: any) => TAURI_API_EVENT.once(name, arg);
case "emit":
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
}
},
});
},
},
);
}