Browse Source

desktop: use tracing for logging (#13135)

Brendan Allan 2 weeks ago
parent
commit
a25b2af05a

+ 96 - 6
packages/desktop/src-tauri/Cargo.lock

@@ -535,8 +535,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
 dependencies = [
  "iana-time-zone",
+ "js-sys",
  "num-traits",
  "serde",
+ "wasm-bindgen",
  "windows-link 0.2.1",
 ]
 
@@ -2491,6 +2493,15 @@ dependencies = [
  "syn 2.0.110",
 ]
 
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
 [[package]]
 name = "matches"
 version = "0.1.10"
@@ -2691,6 +2702,15 @@ dependencies = [
  "zbus",
 ]
 
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
 [[package]]
 name = "num-conv"
 version = "0.1.0"
@@ -3065,6 +3085,7 @@ dependencies = [
 name = "opencode-desktop"
 version = "0.0.0"
 dependencies = [
+ "chrono",
  "comrak",
  "dirs",
  "futures",
@@ -3096,6 +3117,9 @@ dependencies = [
  "tauri-plugin-window-state",
  "tauri-specta",
  "tokio",
+ "tracing",
+ "tracing-appender",
+ "tracing-subscriber",
  "uuid",
  "webkit2gtk",
  "windows 0.61.3",
@@ -4412,6 +4436,15 @@ dependencies = [
  "digest",
 ]
 
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
 [[package]]
 name = "shared_child"
 version = "1.1.1"
@@ -5472,6 +5505,15 @@ dependencies = [
  "syn 2.0.110",
 ]
 
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
 [[package]]
 name = "tiff"
 version = "0.10.3"
@@ -5745,20 +5787,32 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
 
 [[package]]
 name = "tracing"
-version = "0.1.41"
+version = "0.1.44"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
 dependencies = [
  "pin-project-lite",
  "tracing-attributes",
  "tracing-core",
 ]
 
+[[package]]
+name = "tracing-appender"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
+dependencies = [
+ "crossbeam-channel",
+ "thiserror 2.0.17",
+ "time",
+ "tracing-subscriber",
+]
+
 [[package]]
 name = "tracing-attributes"
-version = "0.1.30"
+version = "0.1.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -5767,11 +5821,41 @@ dependencies = [
 
 [[package]]
 name = "tracing-core"
-version = "0.1.34"
+version = "0.1.36"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
 dependencies = [
  "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
 ]
 
 [[package]]
@@ -5964,6 +6048,12 @@ dependencies = [
  "wasm-bindgen",
 ]
 
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
 [[package]]
 name = "version-compare"
 version = "0.2.1"

+ 4 - 0
packages/desktop/src-tauri/Cargo.toml

@@ -47,6 +47,10 @@ specta = "=2.0.0-rc.22"
 specta-typescript = "0.0.9"
 tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
 dirs = "6.0.0"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+tracing-appender = "0.2"
+chrono = "0.4"
 
 [target.'cfg(target_os = "linux")'.dependencies]
 gtk = "0.18.2"

+ 22 - 82
packages/desktop/src-tauri/src/cli.rs

@@ -6,10 +6,7 @@ use tauri_plugin_shell::{
 use tauri_plugin_store::StoreExt;
 use tokio::sync::oneshot;
 
-use crate::{
-    LogState,
-    constants::{MAX_LOG_ENTRIES, SETTINGS_STORE, WSL_ENABLED_KEY},
-};
+use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY};
 
 const CLI_INSTALL_DIR: &str = ".opencode/bin";
 const CLI_BINARY_NAME: &str = "opencode";
@@ -29,7 +26,7 @@ pub async fn get_config(app: &AppHandle) -> Option<Config> {
     create_command(app, "debug config", &[])
         .output()
         .await
-        .inspect_err(|e| eprintln!("Failed to read OC config: {e}"))
+        .inspect_err(|e| tracing::warn!("Failed to read OC config: {e}"))
         .ok()
         .and_then(|out| String::from_utf8(out.stdout.to_vec()).ok())
         .and_then(|s| serde_json::from_str::<Config>(&s).ok())
@@ -104,12 +101,12 @@ pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
 
 pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
     if cfg!(debug_assertions) {
-        println!("Skipping CLI sync for debug build");
+        tracing::debug!("Skipping CLI sync for debug build");
         return Ok(());
     }
 
     if !is_cli_installed() {
-        println!("No CLI installation found, skipping sync");
+        tracing::info!("No CLI installation found, skipping sync");
         return Ok(());
     }
 
@@ -132,21 +129,21 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
     let app_version = app.package_info().version.clone();
 
     if cli_version >= app_version {
-        println!(
-            "CLI version {} is up to date (app version: {}), skipping sync",
-            cli_version, app_version
+        tracing::info!(
+            %cli_version, %app_version,
+            "CLI is up to date, skipping sync"
         );
         return Ok(());
     }
 
-    println!(
-        "CLI version {} is older than app version {}, syncing",
-        cli_version, app_version
+    tracing::info!(
+        %cli_version, %app_version,
+        "CLI is older than app version, syncing"
     );
 
     install_cli(app)?;
 
-    println!("Synced installed CLI");
+    tracing::info!("Synced installed CLI");
 
     Ok(())
 }
@@ -207,7 +204,7 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St
 
     if cfg!(windows) {
         if is_wsl_enabled(app) {
-            println!("WSL is enabled, spawning CLI server in WSL.");
+            tracing::info!("WSL is enabled, spawning CLI server in WSL");
             let version = app.package_info().version.to_string();
             let mut script = vec![
                 "set -e".to_string(),
@@ -280,38 +277,9 @@ pub fn serve(
     port: u32,
     password: &str,
 ) -> (CommandChild, oneshot::Receiver<TerminatedPayload>) {
-    let log_state = app.state::<LogState>();
-    let log_state_clone = log_state.inner().clone();
-
     let (exit_tx, exit_rx) = oneshot::channel::<TerminatedPayload>();
 
-    println!("spawning sidecar on port {port}");
-
-    if let Ok(mut logs) = log_state_clone.0.lock() {
-        let args =
-            format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}");
-
-        #[cfg(target_os = "windows")]
-        {
-            logs.push_back(format!("[SPAWN] sidecar=opencode-cli args=\"{args}\"\n"));
-        }
-
-        #[cfg(not(target_os = "windows"))]
-        {
-            let sidecar = get_sidecar_path(app);
-            let shell = get_user_shell();
-            let cmd = if shell.ends_with("/nu") {
-                format!("^\"{}\" {}", sidecar.display(), args)
-            } else {
-                format!("\"{}\" {}", sidecar.display(), args)
-            };
-            logs.push_back(format!("[SPAWN] shell=\"{shell}\" argv=\"-il -c {cmd}\"\n"));
-        }
-
-        while logs.len() > MAX_LOG_ENTRIES {
-            logs.pop_front();
-        }
-    }
+    tracing::info!(port, "Spawning sidecar");
 
     let envs = [
         ("OPENCODE_SERVER_USERNAME", "opencode".to_string()),
@@ -332,50 +300,22 @@ pub fn serve(
             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();
-                        }
-                    }
+                    tracing::info!(target: "sidecar", "{line}");
                 }
                 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();
-                        }
-                    }
+                    tracing::info!(target: "sidecar", "{line}");
                 }
                 CommandEvent::Error(err) => {
-                    eprintln!("{err}");
-
-                    if let Ok(mut logs) = log_state_clone.0.lock() {
-                        logs.push_back(format!("[ERROR] {err}\n"));
-                        while logs.len() > MAX_LOG_ENTRIES {
-                            logs.pop_front();
-                        }
-                    }
+                    tracing::error!(target: "sidecar", "{err}");
                 }
                 CommandEvent::Terminated(payload) => {
-                    if let Ok(mut logs) = log_state_clone.0.lock() {
-                        logs.push_back(format!(
-                            "[EXIT] code={:?} signal={:?}\n",
-                            payload.code, payload.signal
-                        ));
-                        while logs.len() > MAX_LOG_ENTRIES {
-                            logs.pop_front();
-                        }
-                    }
+                    tracing::info!(
+                        target: "sidecar",
+                        code = ?payload.code,
+                        signal = ?payload.signal,
+                        "Sidecar terminated"
+                    );
 
                     if let Some(tx) = exit_tx.take() {
                         let _ = tx.send(payload);

+ 0 - 1
packages/desktop/src-tauri/src/constants.rs

@@ -4,7 +4,6 @@ pub const SETTINGS_STORE: &str = "opencode.settings.dat";
 pub const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
 pub const WSL_ENABLED_KEY: &str = "wslEnabled";
 pub const UPDATER_ENABLED: bool = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
-pub const MAX_LOG_ENTRIES: usize = 200;
 
 pub fn window_state_flags() -> StateFlags {
     StateFlags::all() - StateFlags::DECORATIONS - StateFlags::VISIBLE

+ 6 - 6
packages/desktop/src-tauri/src/job_object.rs

@@ -15,9 +15,9 @@ use std::io::{Error, Result};
 use std::sync::Mutex;
 use windows::Win32::Foundation::{CloseHandle, HANDLE};
 use windows::Win32::System::JobObjects::{
-    AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
-    JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
-    SetInformationJobObject,
+    AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
+    SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
+    JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
 };
 use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
 
@@ -111,7 +111,7 @@ impl JobObjectState {
                 error: Mutex::new(None),
             },
             Err(e) => {
-                eprintln!("Failed to create job object: {e}");
+                tracing::error!("Failed to create job object: {e}");
                 Self {
                     job: Mutex::new(None),
                     error: Mutex::new(Some(format!("Failed to create job object: {e}"))),
@@ -123,11 +123,11 @@ impl JobObjectState {
     pub fn assign_pid(&self, pid: u32) {
         if let Some(job) = self.job.lock().unwrap().as_ref() {
             if let Err(e) = job.assign_pid(pid) {
-                eprintln!("Failed to assign process {pid} to job object: {e}");
+                tracing::error!(pid, "Failed to assign process to job object: {e}");
                 *self.error.lock().unwrap() =
                     Some(format!("Failed to assign process to job object: {e}"));
             } else {
-                println!("Assigned process {pid} to job object for automatic cleanup");
+                tracing::info!(pid, "Assigned process to job object for automatic cleanup");
             }
         }
     }

+ 37 - 46
packages/desktop/src-tauri/src/lib.rs

@@ -4,6 +4,7 @@ mod constants;
 mod job_object;
 #[cfg(target_os = "linux")]
 pub mod linux_display;
+mod logging;
 mod markdown;
 mod server;
 mod window_customizer;
@@ -16,7 +17,6 @@ use futures::{
 #[cfg(windows)]
 use job_object::*;
 use std::{
-    collections::VecDeque,
     env,
     net::TcpListener,
     path::PathBuf,
@@ -85,14 +85,11 @@ impl ServerState {
     }
 }
 
-#[derive(Clone)]
-struct LogState(Arc<Mutex<VecDeque<String>>>);
-
 #[tauri::command]
 #[specta::specta]
 fn kill_sidecar(app: AppHandle) {
     let Some(server_state) = app.try_state::<ServerState>() else {
-        println!("Server not running");
+        tracing::info!("Server not running");
         return;
     };
 
@@ -102,24 +99,17 @@ fn kill_sidecar(app: AppHandle) {
         .expect("Failed to acquire mutex lock")
         .take()
     else {
-        println!("Server state missing");
+        tracing::info!("Server state missing");
         return;
     };
 
     let _ = server_state.kill();
 
-    println!("Killed server");
+    tracing::info!("Killed server");
 }
 
-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_logs() -> String {
+    logging::tail()
 }
 
 #[tauri::command]
@@ -715,10 +705,18 @@ pub fn run() {
         .plugin(tauri_plugin_decorum::init())
         .invoke_handler(builder.invoke_handler())
         .setup(move |app| {
-            let app = app.handle().clone();
+            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(&app);
-            tauri::async_runtime::spawn(initialize(app));
+            builder.mount_events(&handle);
+            tauri::async_runtime::spawn(initialize(handle));
 
             Ok(())
         });
@@ -732,7 +730,7 @@ pub fn run() {
         .expect("error while running tauri application")
         .run(|app, event| {
             if let RunEvent::Exit = event {
-                println!("Received Exit");
+                tracing::info!("Received Exit");
 
                 kill_sidecar(app.clone());
             }
@@ -780,9 +778,8 @@ fn test_export_types() {
 #[derive(tauri_specta::Event, serde::Deserialize, specta::Type)]
 struct LoadingWindowComplete;
 
-// #[tracing::instrument(skip_all)]
 async fn initialize(app: AppHandle) {
-    println!("Initializing app");
+    tracing::info!("Initializing app");
 
     let (init_tx, init_rx) = watch::channel(InitStep::ServerWaiting);
 
@@ -795,7 +792,7 @@ async fn initialize(app: AppHandle) {
 
     let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
 
-    println!("Main and loading windows created");
+    tracing::info!("Main and loading windows created");
 
     let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some();
 
@@ -806,7 +803,7 @@ async fn initialize(app: AppHandle) {
         async move {
             let mut sqlite_exists = sqlite_file_exists();
 
-            println!("Setting up server connection");
+            tracing::info!("Setting up server connection");
             let server_connection = setup_server_connection(app.clone()).await;
 
             // we delay spawning this future so that the timeout is created lazily
@@ -831,16 +828,13 @@ async fn initialize(app: AppHandle) {
                             if let Some(err) = err {
                                 let _ = child.kill();
 
-                                let logs = get_logs(app.clone())
-                                    .await
-                                    .unwrap_or_else(|e| format!("[DESKTOP] Failed to read sidecar logs: {e}\n"));
-
                                 return Err(format!(
-                                    "Failed to spawn OpenCode Server ({err}). Logs:\n{logs}"
+                                    "Failed to spawn OpenCode Server ({err}). Logs:\n{}",
+                                    get_logs()
                                 ));
                             }
 
-                            println!("CLI health check OK");
+                            tracing::info!("CLI health check OK");
 
                             #[cfg(windows)]
                             {
@@ -868,11 +862,11 @@ async fn initialize(app: AppHandle) {
 
             if let Some(cli_health_check) = cli_health_check {
                 if sqlite_enabled {
-                    println!("Does sqlite file exist: {sqlite_exists}");
+                    tracing::debug!(sqlite_exists, "Checking sqlite file existence");
                     if !sqlite_exists {
-                        println!(
-                            "Sqlite file not found at {}, waiting for it to be generated",
-                            opencode_db_path().expect("failed to get db path").display()
+                        tracing::info!(
+                            path = %opencode_db_path().expect("failed to get db path").display(),
+                            "Sqlite file not found, waiting for it to be generated"
                         );
                         let _ = init_tx.send(InitStep::SqliteWaiting);
 
@@ -897,7 +891,7 @@ async fn initialize(app: AppHandle) {
             .await
             .is_err()
     {
-        println!("Loading task timed out, showing loading window");
+        tracing::debug!("Loading task timed out, showing loading window");
         let app = app.clone();
         let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window");
         sleep(Duration::from_secs(1)).await;
@@ -910,14 +904,14 @@ async fn initialize(app: AppHandle) {
 
     let _ = loading_task.await;
 
-    println!("Loading done, completing initialisation");
+    tracing::info!("Loading done, completing initialisation");
 
     let _ = init_tx.send(InitStep::Done);
 
     if loading_window.is_some() {
         loading_window_complete.await;
 
-        println!("Loading window completed");
+        tracing::info!("Loading window completed");
     }
 
     MainWindow::create(&app).expect("Failed to create main window");
@@ -931,9 +925,6 @@ 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();
 
-    // Initialize log state
-    app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
-
     #[cfg(windows)]
     app.manage(JobObjectState::new());
 
@@ -943,7 +934,7 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
 fn spawn_cli_sync_task(app: AppHandle) {
     tokio::spawn(async move {
         if let Err(e) = sync_cli(app) {
-            eprintln!("Failed to sync CLI: {e}");
+            tracing::error!("Failed to sync CLI: {e}");
         }
     });
 }
@@ -963,12 +954,12 @@ enum ServerConnection {
 async fn setup_server_connection(app: AppHandle) -> ServerConnection {
     let custom_url = get_saved_server_url(&app).await;
 
-    println!("Attempting server connection to custom url: {custom_url:?}");
+    tracing::info!(?custom_url, "Attempting server connection");
 
     if let Some(url) = custom_url
         && server::check_health_or_ask_retry(&app, &url).await
     {
-        println!("Connected to custom server: {}", url);
+        tracing::info!(%url, "Connected to custom server");
         return ServerConnection::Existing { url: url.clone() };
     }
 
@@ -976,15 +967,15 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection {
     let hostname = "127.0.0.1";
     let local_url = format!("http://{hostname}:{local_port}");
 
-    println!("Checking health of server '{}'", local_url);
+    tracing::debug!(url = %local_url, "Checking health of local server");
     if server::check_health(&local_url, None).await {
-        println!("Health check OK, using existing server");
+        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();
 
-    println!("Spawning new local server");
+    tracing::info!("Spawning new local server");
     let (child, health_check) =
         server::spawn_local_server(app, hostname.to_string(), local_port, password.clone());
 

+ 9 - 6
packages/desktop/src-tauri/src/linux_display.rs

@@ -14,7 +14,11 @@ struct DisplayConfig {
 }
 
 fn dir() -> Option<PathBuf> {
-  Some(dirs::data_dir()?.join(if cfg!(debug_assertions) { "ai.opencode.desktop.dev" } else { "ai.opencode.desktop" }))
+    Some(dirs::data_dir()?.join(if cfg!(debug_assertions) {
+        "ai.opencode.desktop.dev"
+    } else {
+        "ai.opencode.desktop"
+    }))
 }
 
 fn path() -> Option<PathBuf> {
@@ -22,13 +26,12 @@ fn path() -> Option<PathBuf> {
 }
 
 pub fn read_wayland() -> Option<bool> {
-    let raw = std::fs::read_to_string(dbg!(path()?)).ok()?;
+    let raw = std::fs::read_to_string(path()?).ok()?;
     let root = serde_json::from_str::<serde_json::Value>(&raw)
         .ok()?
-        .get(LINUX_DISPLAY_CONFIG_KEY).cloned()?;
-    serde_json::from_value::<DisplayConfig>(root)
-        .ok()?
-        .wayland
+        .get(LINUX_DISPLAY_CONFIG_KEY)
+        .cloned()?;
+    serde_json::from_value::<DisplayConfig>(root).ok()?.wayland
 }
 
 pub fn write_wayland(app: &AppHandle, value: bool) -> Result<(), String> {

+ 83 - 0
packages/desktop/src-tauri/src/logging.rs

@@ -0,0 +1,83 @@
+use std::fs::File;
+use std::io::{BufRead, BufReader};
+use std::path::{Path, PathBuf};
+use tracing_appender::non_blocking::WorkerGuard;
+use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
+
+const MAX_LOG_AGE_DAYS: u64 = 7;
+const TAIL_LINES: usize = 1000;
+
+static LOG_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
+
+pub fn init(log_dir: &Path) -> WorkerGuard {
+    std::fs::create_dir_all(log_dir).expect("failed to create log directory");
+
+    cleanup(log_dir);
+
+    let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S");
+    let filename = format!("opencode-desktop_{timestamp}.log");
+    let log_path = log_dir.join(&filename);
+
+    LOG_PATH
+        .set(log_path.clone())
+        .expect("logging already initialized");
+
+    let file = File::create(&log_path).expect("failed to create log file");
+    let (non_blocking, guard) = tracing_appender::non_blocking(file);
+
+    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
+        if cfg!(debug_assertions) {
+            EnvFilter::new("opencode_lib=debug,opencode_desktop=debug,sidecar=debug")
+        } else {
+            EnvFilter::new("opencode_lib=info,opencode_desktop=info,sidecar=info")
+        }
+    });
+
+    tracing_subscriber::registry()
+        .with(filter)
+        .with(fmt::layer().with_writer(std::io::stderr))
+        .with(
+            fmt::layer()
+                .with_writer(non_blocking)
+                .with_ansi(false),
+        )
+        .init();
+
+    guard
+}
+
+pub fn tail() -> String {
+    let Some(path) = LOG_PATH.get() else {
+        return String::new();
+    };
+
+    let Ok(file) = File::open(path) else {
+        return String::new();
+    };
+
+    let lines: Vec<String> = BufReader::new(file)
+        .lines()
+        .map_while(Result::ok)
+        .collect();
+
+    let start = lines.len().saturating_sub(TAIL_LINES);
+    lines[start..].join("\n")
+}
+
+fn cleanup(log_dir: &Path) {
+    let cutoff = std::time::SystemTime::now()
+        - std::time::Duration::from_secs(MAX_LOG_AGE_DAYS * 24 * 60 * 60);
+
+    let Ok(entries) = std::fs::read_dir(log_dir) else {
+        return;
+    };
+
+    for entry in entries.flatten() {
+        if let Ok(meta) = entry.metadata()
+            && let Ok(modified) = meta.modified()
+            && modified < cutoff
+        {
+            let _ = std::fs::remove_file(entry.path());
+        }
+    }
+}

+ 2 - 2
packages/desktop/src-tauri/src/main.rs

@@ -43,7 +43,7 @@ fn configure_display_backend() -> Option<String> {
         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."
+                Set OC_ALLOW_WAYLAND=1 to keep native Wayland."
                 .into(),
         );
     }
@@ -86,7 +86,7 @@ fn main() {
     #[cfg(target_os = "linux")]
     {
         if let Some(backend_note) = configure_display_backend() {
-            eprintln!("{backend_note:?}");
+            eprintln!("{backend_note}");
         }
     }
 

+ 5 - 5
packages/desktop/src-tauri/src/server.rs

@@ -93,14 +93,14 @@ pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> {
 
 pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> {
     if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
-        println!("Using desktop-specific custom URL: {url}");
+        tracing::info!(%url, "Using desktop-specific custom URL");
         return Some(url);
     }
 
     if let Some(cli_config) = cli::get_config(app).await
         && let Some(url) = get_server_url_from_config(&cli_config)
     {
-        println!("Using custom server URL from config: {url}");
+        tracing::info!(%url, "Using custom server URL from config");
         return Some(url);
     }
 
@@ -124,7 +124,7 @@ pub fn spawn_local_server(
                 tokio::time::sleep(Duration::from_millis(100)).await;
 
                 if check_health(&url, Some(&password)).await {
-                    println!("Server ready after {:?}", timestamp.elapsed());
+                    tracing::info!(elapsed = ?timestamp.elapsed(), "Server ready");
                     return Ok(());
                 }
             }
@@ -216,7 +216,7 @@ fn normalize_hostname_for_url(hostname: &str) -> String {
 fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
     let server = config.server.as_ref()?;
     let port = server.port?;
-    println!("server.port found in OC config: {port}");
+    tracing::debug!(port, "server.port found in OC config");
     let hostname = server
         .hostname
         .as_ref()
@@ -227,7 +227,7 @@ fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
 }
 
 pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool {
-    println!("Checking health for {url}");
+    tracing::debug!(%url, "Checking health");
     loop {
         if check_health(url, None).await {
             return true;