summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src-tauri/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-22 20:01:16 -0600
committerAdam <[email protected]>2025-12-22 20:01:25 -0600
commit81c3c638953fb7730a6965b3614d57e3c4919dde (patch)
treebea87a6b98078d52dd037da16500b29a28587edc /packages/desktop/src-tauri/src
parentb76bd4141dd78db94ab485743b4dc21d918cd85e (diff)
downloadopencode-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.rs308
-rw-r--r--packages/desktop/src-tauri/src/main.rs63
-rw-r--r--packages/desktop/src-tauri/src/window_customizer.rs34
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());
+ }
+ }
+ });
+ }
+}