diff options
| author | Adam <[email protected]> | 2025-12-22 20:01:16 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-22 20:01:25 -0600 |
| commit | 81c3c638953fb7730a6965b3614d57e3c4919dde (patch) | |
| tree | bea87a6b98078d52dd037da16500b29a28587edc /packages/desktop/src-tauri/src | |
| parent | b76bd4141dd78db94ab485743b4dc21d918cd85e (diff) | |
| download | opencode-81c3c638953fb7730a6965b3614d57e3c4919dde.tar.gz opencode-81c3c638953fb7730a6965b3614d57e3c4919dde.zip | |
chore: rename packages/tauri -> packages/desktop
Diffstat (limited to 'packages/desktop/src-tauri/src')
| -rw-r--r-- | packages/desktop/src-tauri/src/lib.rs | 308 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/main.rs | 63 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/window_customizer.rs | 34 |
3 files changed, 405 insertions, 0 deletions
diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs new file mode 100644 index 000000000..3c08841ab --- /dev/null +++ b/packages/desktop/src-tauri/src/lib.rs @@ -0,0 +1,308 @@ +mod window_customizer; + +use std::{ + collections::VecDeque, + net::{SocketAddr, TcpListener}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory}; +use tauri_plugin_clipboard_manager::ClipboardExt; +use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; +use tauri_plugin_shell::process::{CommandChild, CommandEvent}; +use tauri_plugin_shell::ShellExt; +use tokio::net::TcpSocket; + +use crate::window_customizer::PinchZoomDisablePlugin; + +#[derive(Clone)] +struct ServerState(Arc<Mutex<Option<CommandChild>>>); + +#[derive(Clone)] +struct LogState(Arc<Mutex<VecDeque<String>>>); + +const MAX_LOG_ENTRIES: usize = 200; + +#[tauri::command] +fn kill_sidecar(app: AppHandle) { + let Some(server_state) = app.try_state::<ServerState>() else { + println!("Server not running"); + return; + }; + + let Some(server_state) = server_state + .0 + .lock() + .expect("Failed to acquire mutex lock") + .take() + else { + println!("Server state missing"); + return; + }; + + let _ = server_state.kill(); + + println!("Killed server"); +} + +#[tauri::command] +async fn copy_logs_to_clipboard(app: AppHandle) -> Result<(), String> { + let log_state = app.try_state::<LogState>().ok_or("Log state not found")?; + + let logs = log_state + .0 + .lock() + .map_err(|_| "Failed to acquire log lock")?; + + let log_text = logs.iter().cloned().collect::<Vec<_>>().join(""); + + app.clipboard() + .write_text(log_text) + .map_err(|e| format!("Failed to copy to clipboard: {}", e))?; + + Ok(()) +} + +#[tauri::command] +async fn get_logs(app: AppHandle) -> Result<String, String> { + let log_state = app.try_state::<LogState>().ok_or("Log state not found")?; + + let logs = log_state + .0 + .lock() + .map_err(|_| "Failed to acquire log lock")?; + + Ok(logs.iter().cloned().collect::<Vec<_>>().join("")) +} + +fn get_sidecar_port() -> u32 { + option_env!("OPENCODE_PORT") + .map(|s| s.to_string()) + .or_else(|| std::env::var("OPENCODE_PORT").ok()) + .and_then(|port_str| port_str.parse().ok()) + .unwrap_or_else(|| { + TcpListener::bind("127.0.0.1:0") + .expect("Failed to bind to find free port") + .local_addr() + .expect("Failed to get local address") + .port() + }) as u32 +} + +fn get_user_shell() -> String { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) +} + +fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild { + let log_state = app.state::<LogState>(); + let log_state_clone = log_state.inner().clone(); + + let state_dir = app + .path() + .resolve("", BaseDirectory::AppLocalData) + .expect("Failed to resolve app local data dir"); + + #[cfg(target_os = "windows")] + let (mut rx, child) = app + .shell() + .sidecar("opencode-cli") + .unwrap() + .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") + .env("OPENCODE_CLIENT", "desktop") + .env("XDG_STATE_HOME", &state_dir) + .args(["serve", &format!("--port={port}")]) + .spawn() + .expect("Failed to spawn opencode"); + + #[cfg(not(target_os = "windows"))] + let (mut rx, child) = { + let sidecar_path = tauri::utils::platform::current_exe() + .expect("Failed to get current exe") + .parent() + .expect("Failed to get parent dir") + .join("opencode-cli"); + let shell = get_user_shell(); + app.shell() + .command(&shell) + .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") + .env("OPENCODE_CLIENT", "desktop") + .env("XDG_STATE_HOME", &state_dir) + .args([ + "-il", + "-c", + &format!("{} serve --port={}", sidecar_path.display(), port), + ]) + .spawn() + .expect("Failed to spawn opencode") + }; + + tauri::async_runtime::spawn(async move { + while let Some(event) = rx.recv().await { + match event { + CommandEvent::Stdout(line_bytes) => { + let line = String::from_utf8_lossy(&line_bytes); + print!("{line}"); + + // Store log in shared state + if let Ok(mut logs) = log_state_clone.0.lock() { + logs.push_back(format!("[STDOUT] {}", line)); + // Keep only the last MAX_LOG_ENTRIES + while logs.len() > MAX_LOG_ENTRIES { + logs.pop_front(); + } + } + } + CommandEvent::Stderr(line_bytes) => { + let line = String::from_utf8_lossy(&line_bytes); + eprint!("{line}"); + + // Store log in shared state + if let Ok(mut logs) = log_state_clone.0.lock() { + logs.push_back(format!("[STDERR] {}", line)); + // Keep only the last MAX_LOG_ENTRIES + while logs.len() > MAX_LOG_ENTRIES { + logs.pop_front(); + } + } + } + _ => {} + } + } + }); + + child +} + +async fn is_server_running(port: u32) -> bool { + TcpSocket::new_v4() + .unwrap() + .connect(SocketAddr::new( + "127.0.0.1".parse().expect("Failed to parse IP"), + port as u16, + )) + .await + .is_ok() +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); + + let mut builder = tauri::Builder::default() + .plugin(tauri_plugin_os::init()) + .plugin(tauri_plugin_window_state::Builder::new().build()) + .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_clipboard_manager::init()) + .plugin(tauri_plugin_http::init()) + .plugin(PinchZoomDisablePlugin) + .invoke_handler(tauri::generate_handler![ + kill_sidecar, + copy_logs_to_clipboard, + get_logs + ]) + .setup(move |app| { + let app = app.handle().clone(); + + // Initialize log state + app.manage(LogState(Arc::new(Mutex::new(VecDeque::new())))); + + tauri::async_runtime::spawn(async move { + let port = get_sidecar_port(); + + let should_spawn_sidecar = !is_server_running(port).await; + + let child = if should_spawn_sidecar { + let child = spawn_sidecar(&app, port); + + let timestamp = Instant::now(); + loop { + if timestamp.elapsed() > Duration::from_secs(7) { + let res = app.dialog() + .message("Failed to spawn OpenCode Server. Copy logs using the button below and send them to the team for assistance.") + .title("Startup Failed") + .buttons(MessageDialogButtons::OkCancelCustom("Copy Logs And Exit".to_string(), "Exit".to_string())) + .blocking_show_with_result(); + + if matches!(&res, MessageDialogResult::Custom(name) if name == "Copy Logs And Exit") { + match copy_logs_to_clipboard(app.clone()).await { + Ok(()) => println!("Logs copied to clipboard successfully"), + Err(e) => println!("Failed to copy logs to clipboard: {}", e), + } + } + + app.exit(1); + + return; + } + + tokio::time::sleep(Duration::from_millis(10)).await; + + if is_server_running(port).await { + // give the server a little bit more time to warm up + tokio::time::sleep(Duration::from_millis(10)).await; + + break; + } + } + + println!("Server ready after {:?}", timestamp.elapsed()); + + Some(child) + } else { + None + }; + + let primary_monitor = app.primary_monitor().ok().flatten(); + let size = primary_monitor + .map(|m| m.size().to_logical(m.scale_factor())) + .unwrap_or(LogicalSize::new(1920, 1080)); + + let mut window_builder = + WebviewWindow::builder(&app, "main", WebviewUrl::App("/".into())) + .title("OpenCode") + .inner_size(size.width as f64, size.height as f64) + .decorations(true) + .zoom_hotkeys_enabled(true) + .disable_drag_drop_handler() + .initialization_script(format!( + r#" + window.__OPENCODE__ ??= {{}}; + window.__OPENCODE__.updaterEnabled = {updater_enabled}; + window.__OPENCODE__.port = {port}; + "# + )); + + #[cfg(target_os = "macos")] + { + window_builder = window_builder + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true); + } + + window_builder.build().expect("Failed to create window"); + + app.manage(ServerState(Arc::new(Mutex::new(child)))); + }); + + Ok(()) + }); + + if updater_enabled { + builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); + } + + builder + .build(tauri::generate_context!()) + .expect("error while running tauri application") + .run(|app, event| { + if let RunEvent::Exit = event { + println!("Received Exit"); + + kill_sidecar(app.clone()); + } + }); +} diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs new file mode 100644 index 000000000..b215f8c55 --- /dev/null +++ b/packages/desktop/src-tauri/src/main.rs @@ -0,0 +1,63 @@ +// Prevents additional console window on Windows in release, DO NOT REMOVE!! +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +// borrowed from https://github.com/skyline69/balatro-mod-manager +#[cfg(target_os = "linux")] +fn configure_display_backend() -> Option<String> { + use std::env; + + let set_env_if_absent = |key: &str, value: &str| { + if env::var_os(key).is_none() { + // Safety: called during startup before any threads are spawned, so mutating the + // process environment is safe. + unsafe { env::set_var(key, value) }; + } + }; + + let on_wayland = env::var_os("WAYLAND_DISPLAY").is_some() + || matches!( + env::var("XDG_SESSION_TYPE"), + Ok(v) if v.eq_ignore_ascii_case("wayland") + ); + if !on_wayland { + return None; + } + + // Allow users to explicitly keep Wayland if they know their setup is stable. + let allow_wayland = matches!( + env::var("OC_ALLOW_WAYLAND"), + Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes") + ); + if allow_wayland { + return Some("Wayland session detected; respecting OC_ALLOW_WAYLAND=1".into()); + } + + // Prefer XWayland when available to avoid Wayland protocol errors seen during startup. + if env::var_os("DISPLAY").is_some() { + set_env_if_absent("WINIT_UNIX_BACKEND", "x11"); + set_env_if_absent("GDK_BACKEND", "x11"); + set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + return Some( + "Wayland session detected; forcing X11 backend to avoid compositor protocol errors. \ + Set OC_ALLOW_WAYLAND=1 to keep native Wayland." + .into(), + ); + } + + set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); + Some( + "Wayland session detected without X11; leaving Wayland enabled (set WINIT_UNIX_BACKEND/GDK_BACKEND manually if needed)." + .into(), + ) +} + +fn main() { + #[cfg(target_os = "linux")] + { + if let Some(backend_note) = configure_display_backend() { + eprintln!("{backend_note:?}"); + } + } + + opencode_lib::run() +} diff --git a/packages/desktop/src-tauri/src/window_customizer.rs b/packages/desktop/src-tauri/src/window_customizer.rs new file mode 100644 index 000000000..cd42fd029 --- /dev/null +++ b/packages/desktop/src-tauri/src/window_customizer.rs @@ -0,0 +1,34 @@ +use tauri::{plugin::Plugin, Manager, Runtime, Window}; + +pub struct PinchZoomDisablePlugin; + +impl Default for PinchZoomDisablePlugin { + fn default() -> Self { + Self + } +} + +impl<R: Runtime> Plugin<R> for PinchZoomDisablePlugin { + fn name(&self) -> &'static str { + "Does not matter here" + } + + fn window_created(&mut self, window: Window<R>) { + let Some(webview_window) = window.get_webview_window(window.label()) else { + return; + }; + + let _ = webview_window.with_webview(|_webview| { + #[cfg(target_os = "linux")] + unsafe { + use gtk::glib::ObjectExt; + use gtk::GestureZoom; + use webkit2gtk::glib::gobject_ffi; + + if let Some(data) = _webview.inner().data::<GestureZoom>("wk-view-zoom-gesture") { + gobject_ffi::g_signal_handlers_destroy(data.as_ptr().cast()); + } + } + }); + } +} |
