| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825 |
- mod cli;
- mod constants;
- #[cfg(windows)]
- mod job_object;
- #[cfg(target_os = "linux")]
- pub mod linux_display;
- mod logging;
- mod markdown;
- mod server;
- mod window_customizer;
- mod windows;
- use futures::{
- FutureExt, TryFutureExt,
- future::{self, Shared},
- };
- #[cfg(windows)]
- use job_object::*;
- use std::{
- env,
- net::TcpListener,
- path::PathBuf,
- process::Command,
- sync::{Arc, Mutex},
- time::Duration,
- };
- use tauri::{AppHandle, Listener, Manager, RunEvent, State, ipc::Channel};
- #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
- use tauri_plugin_deep_link::DeepLinkExt;
- use tauri_plugin_shell::process::CommandChild;
- use tauri_specta::Event;
- use tokio::{
- sync::{oneshot, watch},
- time::{sleep, timeout},
- };
- use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
- use crate::constants::*;
- use crate::server::get_saved_server_url;
- use crate::windows::{LoadingWindow, MainWindow};
- #[derive(Clone, serde::Serialize, specta::Type, Debug)]
- struct ServerReadyData {
- url: String,
- password: Option<String>,
- }
- #[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)]
- #[serde(tag = "phase", rename_all = "snake_case")]
- enum InitStep {
- ServerWaiting,
- SqliteWaiting,
- Done,
- }
- #[derive(serde::Deserialize, specta::Type)]
- #[serde(rename_all = "snake_case")]
- enum WslPathMode {
- Windows,
- Linux,
- }
- struct InitState {
- current: watch::Receiver<InitStep>,
- }
- #[derive(Clone)]
- struct ServerState {
- child: Arc<Mutex<Option<CommandChild>>>,
- status: future::Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
- }
- impl ServerState {
- pub fn new(
- child: Option<CommandChild>,
- status: Shared<oneshot::Receiver<Result<ServerReadyData, String>>>,
- ) -> Self {
- Self {
- child: Arc::new(Mutex::new(child)),
- status,
- }
- }
- pub fn set_child(&self, child: Option<CommandChild>) {
- *self.child.lock().unwrap() = child;
- }
- }
- #[tauri::command]
- #[specta::specta]
- fn kill_sidecar(app: AppHandle) {
- let Some(server_state) = app.try_state::<ServerState>() else {
- tracing::info!("Server not running");
- return;
- };
- let Some(server_state) = server_state
- .child
- .lock()
- .expect("Failed to acquire mutex lock")
- .take()
- else {
- tracing::info!("Server state missing");
- return;
- };
- let _ = server_state.kill();
- tracing::info!("Killed server");
- }
- fn get_logs() -> String {
- logging::tail()
- }
- #[tauri::command]
- #[specta::specta]
- async fn await_initialization(
- state: State<'_, ServerState>,
- init_state: State<'_, InitState>,
- events: Channel<InitStep>,
- ) -> Result<ServerReadyData, String> {
- let mut rx = init_state.current.clone();
- let events = async {
- let e = *rx.borrow();
- let _ = events.send(e);
- while rx.changed().await.is_ok() {
- let step = *rx.borrow_and_update();
- let _ = events.send(step);
- if matches!(step, InitStep::Done) {
- break;
- }
- }
- };
- future::join(state.status.clone(), events)
- .await
- .0
- .map_err(|_| "Failed to get server status".to_string())?
- }
- #[tauri::command]
- #[specta::specta]
- fn check_app_exists(app_name: &str) -> bool {
- #[cfg(target_os = "windows")]
- {
- check_windows_app(app_name)
- }
- #[cfg(target_os = "macos")]
- {
- check_macos_app(app_name)
- }
- #[cfg(target_os = "linux")]
- {
- check_linux_app(app_name)
- }
- }
- #[cfg(target_os = "windows")]
- 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};
- // Try to find the command using 'where'
- let output = Command::new("where").arg(app_name).output().ok()?;
- if !output.status.success() {
- return None;
- }
- 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()
- .and_then(|v| v.to_str())
- .map(|v| v.eq_ignore_ascii_case(ext))
- .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 content = std::fs::read_to_string(path).ok()?;
- for token in content.split('"') {
- let lower = token.to_ascii_lowercase();
- if !lower.contains(".exe") {
- continue;
- }
- if let Some(index) = lower.find("%~dp0") {
- let base = path.parent()?;
- let suffix = &token[index + 5..];
- let mut resolved = PathBuf::from(base);
- for part in suffix.replace('/', "\\").split('\\') {
- if part.is_empty() || part == "." {
- continue;
- }
- if part == ".." {
- let _ = resolved.pop();
- continue;
- }
- resolved.push(part);
- }
- if resolved.exists() {
- return Some(resolved.to_string_lossy().to_string());
- }
- }
- let resolved = PathBuf::from(token);
- if resolved.exists() {
- return Some(resolved.to_string_lossy().to_string());
- }
- }
- None
- };
- for path in &paths {
- if has_ext(path, "cmd") || has_ext(path, "bat") {
- if let Some(resolved) = resolve_cmd(path) {
- 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 key = app_name
- .chars()
- .filter(|v| v.is_ascii_alphanumeric())
- .flat_map(|v| v.to_lowercase())
- .collect::<String>();
- 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()),
- ];
- 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 Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
- continue;
- };
- 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())
- }
- #[tauri::command]
- #[specta::specta]
- fn resolve_app_path(app_name: &str) -> Option<String> {
- #[cfg(target_os = "windows")]
- {
- resolve_windows_app_path(app_name)
- }
- #[cfg(not(target_os = "windows"))]
- {
- // On macOS/Linux, just return the app_name as-is since
- // the opener plugin handles them correctly
- Some(app_name.to_string())
- }
- }
- #[cfg(target_os = "macos")]
- fn check_macos_app(app_name: &str) -> bool {
- // Check common installation locations
- let mut app_locations = vec![
- format!("/Applications/{}.app", app_name),
- format!("/System/Applications/{}.app", app_name),
- ];
- if let Ok(home) = std::env::var("HOME") {
- app_locations.push(format!("{}/Applications/{}.app", home, app_name));
- }
- for location in app_locations {
- if std::path::Path::new(&location).exists() {
- return true;
- }
- }
- // Also check if command exists in PATH
- Command::new("which")
- .arg(app_name)
- .output()
- .map(|output| output.status.success())
- .unwrap_or(false)
- }
- #[derive(serde::Serialize, serde::Deserialize, specta::Type)]
- #[serde(rename_all = "camelCase")]
- pub enum LinuxDisplayBackend {
- Wayland,
- Auto,
- }
- #[tauri::command]
- #[specta::specta]
- fn get_display_backend() -> Option<LinuxDisplayBackend> {
- #[cfg(target_os = "linux")]
- {
- let prefer = linux_display::read_wayland().unwrap_or(false);
- return Some(if prefer {
- LinuxDisplayBackend::Wayland
- } else {
- LinuxDisplayBackend::Auto
- });
- }
- #[cfg(not(target_os = "linux"))]
- None
- }
- #[tauri::command]
- #[specta::specta]
- fn set_display_backend(_app: AppHandle, _backend: LinuxDisplayBackend) -> Result<(), String> {
- #[cfg(target_os = "linux")]
- {
- let prefer = matches!(_backend, LinuxDisplayBackend::Wayland);
- return linux_display::write_wayland(&_app, prefer);
- }
- #[cfg(not(target_os = "linux"))]
- Ok(())
- }
- #[cfg(target_os = "linux")]
- 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 = make_specta_builder();
- #[cfg(debug_assertions)] // <- Only export on non-release builds
- export_types(&builder);
- #[cfg(all(target_os = "macos", not(debug_assertions)))]
- let _ = std::process::Command::new("killall")
- .arg("opencode-cli")
- .output();
- let mut builder = tauri::Builder::default()
- .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
- // Focus existing window when another instance is launched
- if let Some(window) = app.get_webview_window(MainWindow::LABEL) {
- let _ = window.set_focus();
- let _ = window.unminimize();
- }
- }))
- .plugin(tauri_plugin_deep_link::init())
- .plugin(tauri_plugin_os::init())
- .plugin(
- tauri_plugin_window_state::Builder::new()
- .with_state_flags(window_state_flags())
- .with_denylist(&[LoadingWindow::LABEL])
- .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(tauri_plugin_notification::init())
- .plugin(crate::window_customizer::PinchZoomDisablePlugin)
- .plugin(tauri_plugin_decorum::init())
- .invoke_handler(builder.invoke_handler())
- .setup(move |app| {
- let handle = app.handle().clone();
- let log_dir = app
- .path()
- .app_log_dir()
- .expect("failed to resolve app log dir");
- // Hold the guard in managed state so it lives for the app's lifetime,
- // ensuring all buffered logs are flushed on shutdown.
- handle.manage(logging::init(&log_dir));
- builder.mount_events(&handle);
- tauri::async_runtime::spawn(initialize(handle));
- 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 {
- tracing::info!("Received Exit");
- kill_sidecar(app.clone());
- }
- });
- }
- 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,
- SqliteMigrationProgress
- ])
- .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;
- async fn initialize(app: AppHandle) {
- tracing::info!("Initializing app");
- let (init_tx, init_rx) = watch::channel(InitStep::ServerWaiting);
- setup_app(&app, init_rx);
- spawn_cli_sync_task(app.clone());
- let (server_ready_tx, server_ready_rx) = oneshot::channel();
- let server_ready_rx = server_ready_rx.shared();
- app.manage(ServerState::new(None, server_ready_rx.clone()));
- let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
- tracing::info!("Main and loading windows created");
- // SQLite migration handling:
- // We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it
- // First, we spawn a task that listens for SqliteMigrationProgress events that can
- // come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
- // Then in the loading task, we wait for sqlite migration to complete before
- // starting our health check against the server, otherwise long migrations could result in a timeout.
- let needs_sqlite_migration = !sqlite_file_exists();
- let sqlite_done = needs_sqlite_migration.then(|| {
- tracing::info!(
- path = %opencode_db_path().expect("failed to get db path").display(),
- "Sqlite file not found, waiting for it to be generated"
- );
- let (done_tx, done_rx) = oneshot::channel::<()>();
- let done_tx = Arc::new(Mutex::new(Some(done_tx)));
- let init_tx = init_tx.clone();
- let id = SqliteMigrationProgress::listen(&app, move |e| {
- let _ = init_tx.send(InitStep::SqliteWaiting);
- if matches!(e.payload, SqliteMigrationProgress::Done)
- && let Some(done_tx) = done_tx.lock().unwrap().take()
- {
- let _ = done_tx.send(());
- }
- });
- let app = app.clone();
- tokio::spawn(done_rx.map(async move |_| {
- app.unlisten(id);
- }))
- });
- let loading_task = tokio::spawn({
- let app = app.clone();
- async move {
- tracing::info!("Setting up server connection");
- let server_connection = setup_server_connection(app.clone()).await;
- tracing::info!("Server connection setup");
- // we delay spawning this future so that the timeout is created lazily
- let cli_health_check = match server_connection {
- ServerConnection::CLI {
- child,
- health_check,
- url,
- password,
- } => {
- let app = app.clone();
- Some(
- async move {
- let res = timeout(Duration::from_secs(30), health_check.0).await;
- let err = match res {
- Ok(Ok(Ok(()))) => None,
- Ok(Ok(Err(e))) => Some(e),
- Ok(Err(e)) => Some(format!("Health check task failed: {e}")),
- Err(_) => Some("Health check timed out".to_string()),
- };
- if let Some(err) = err {
- let _ = child.kill();
- return Err(format!(
- "Failed to spawn OpenCode Server ({err}). Logs:\n{}",
- get_logs()
- ));
- }
- tracing::info!("CLI health check OK");
- #[cfg(windows)]
- {
- let job_state = app.state::<JobObjectState>();
- job_state.assign_pid(child.pid());
- }
- app.state::<ServerState>().set_child(Some(child));
- Ok(ServerReadyData { url, password })
- }
- .map(move |res| {
- let _ = server_ready_tx.send(res);
- }),
- )
- }
- ServerConnection::Existing { url } => {
- let _ = server_ready_tx.send(Ok(ServerReadyData {
- url: url.to_string(),
- password: None,
- }));
- None
- }
- };
- tracing::info!("server connection started");
- if let Some(cli_health_check) = cli_health_check {
- if let Some(sqlite_done_rx) = sqlite_done {
- let _ = sqlite_done_rx.await;
- }
- tokio::spawn(cli_health_check);
- }
- let _ = server_ready_rx.await;
- tracing::info!("Loading task finished");
- }
- })
- .map_err(|_| ())
- .shared();
- let loading_window = if needs_sqlite_migration
- && timeout(Duration::from_secs(1), loading_task.clone())
- .await
- .is_err()
- {
- tracing::debug!("Loading task timed out, showing loading window");
- let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window");
- sleep(Duration::from_secs(1)).await;
- Some(loading_window)
- } else {
- tracing::debug!("Showing main window without loading window");
- MainWindow::create(&app).expect("Failed to create main window");
- None
- };
- let _ = loading_task.await;
- tracing::info!("Loading done, completing initialisation");
- let _ = init_tx.send(InitStep::Done);
- if loading_window.is_some() {
- loading_window_complete.await;
- tracing::info!("Loading window completed");
- }
- MainWindow::create(&app).expect("Failed to create main window");
- if let Some(loading_window) = loading_window {
- let _ = loading_window.close();
- }
- }
- fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
- #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
- app.deep_link().register_all().ok();
- #[cfg(windows)]
- app.manage(JobObjectState::new());
- app.manage(InitState { current: init_rx });
- }
- fn spawn_cli_sync_task(app: AppHandle) {
- tokio::spawn(async move {
- if let Err(e) = sync_cli(app) {
- tracing::error!("Failed to sync CLI: {e}");
- }
- });
- }
- enum ServerConnection {
- Existing {
- url: String,
- },
- CLI {
- url: String,
- password: Option<String>,
- child: CommandChild,
- health_check: server::HealthCheck,
- },
- }
- async fn setup_server_connection(app: AppHandle) -> ServerConnection {
- let custom_url = get_saved_server_url(&app).await;
- tracing::info!(?custom_url, "Attempting server connection");
- if let Some(url) = custom_url
- && server::check_health_or_ask_retry(&app, &url).await
- {
- tracing::info!(%url, "Connected to custom server");
- return ServerConnection::Existing { url: url.clone() };
- }
- let local_port = get_sidecar_port();
- let hostname = "127.0.0.1";
- let local_url = format!("http://{hostname}:{local_port}");
- tracing::debug!(url = %local_url, "Checking health of local server");
- if server::check_health(&local_url, None).await {
- tracing::info!(url = %local_url, "Health check OK, using existing server");
- return ServerConnection::Existing { url: local_url };
- }
- let password = uuid::Uuid::new_v4().to_string();
- tracing::info!("Spawning new local server");
- let (child, health_check) =
- server::spawn_local_server(app, hostname.to_string(), local_port, password.clone());
- ServerConnection::CLI {
- url: local_url,
- password: Some(password),
- child,
- health_check,
- }
- }
- 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 sqlite_file_exists() -> bool {
- let Ok(path) = opencode_db_path() else {
- return true;
- };
- path.exists()
- }
- fn opencode_db_path() -> Result<PathBuf, &'static str> {
- let xdg_data_home = env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty());
- let data_home = match xdg_data_home {
- Some(v) => PathBuf::from(v),
- None => {
- let home = dirs::home_dir().ok_or("cannot determine home directory")?;
- home.join(".local").join("share")
- }
- };
- Ok(data_home.join("opencode").join("opencode.db"))
- }
- // Creates a `once` listener for the specified event and returns a future that resolves
- // when the listener is fired.
- // Since the future creation and awaiting can be done separately, it's possible to create the listener
- // synchronously before doing something, then awaiting afterwards.
- fn event_once_fut<T: tauri_specta::Event + serde::de::DeserializeOwned>(
- app: &AppHandle,
- ) -> impl Future<Output = ()> {
- let (tx, rx) = oneshot::channel();
- T::once(app, |_| {
- let _ = tx.send(());
- });
- async {
- let _ = rx.await;
- }
- }
|