Files
shopify-ai-backup/opencode/packages/desktop/src-tauri/src/lib.rs
2026-02-07 20:54:46 +00:00

542 lines
16 KiB
Rust

mod cli;
mod constants;
#[cfg(windows)]
mod job_object;
mod markdown;
mod server;
mod window_customizer;
mod windows;
use futures::{
FutureExt, TryFutureExt,
future::{self, Shared},
};
#[cfg(windows)]
use job_object::*;
use std::{
collections::VecDeque,
env,
net::TcpListener,
path::PathBuf,
sync::{Arc, Mutex},
time::Duration,
process::Command,
};
use tauri::{AppHandle, 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 tokio::{
sync::{oneshot, watch},
time::{sleep, timeout},
};
use crate::cli::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,
}
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;
}
}
#[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");
return;
};
let Some(server_state) = server_state
.child
.lock()
.expect("Failed to acquire mutex lock")
.take()
else {
println!("Server state missing");
return;
};
let _ = server_state.kill();
println!("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(""))
}
#[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()).clone();
let _ = events.send(e).unwrap();
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 = "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)
}
#[cfg(target_os = "linux")]
fn check_linux_app(app_name: &str) -> bool {
return true;
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = 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,
markdown::parse_markdown_command,
check_app_exists
])
.events(tauri_specta::collect_events![LoadingWindowComplete])
.error_handling(tauri_specta::ErrorHandlingMode::Throw);
#[cfg(debug_assertions)] // <- Only export on non-release builds
builder
.export(
specta_typescript::Typescript::default(),
"../src/bindings.ts",
)
.expect("Failed to export typescript bindings");
#[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 app = app.handle().clone();
builder.mount_events(&app);
tauri::async_runtime::spawn(initialize(app));
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());
}
});
}
#[derive(tauri_specta::Event, serde::Deserialize, specta::Type)]
struct LoadingWindowComplete;
// #[tracing::instrument(skip_all)]
async fn initialize(app: AppHandle) {
println!("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);
println!("Main and loading windows created");
let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some();
let loading_task = tokio::spawn({
let init_tx = init_tx.clone();
let app = app.clone();
async move {
let mut sqlite_exists = sqlite_file_exists();
println!("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
let cli_health_check = match server_connection {
ServerConnection::CLI {
child,
health_check,
url,
password,
} => {
let app = app.clone();
Some(
async move {
let Ok(Ok(_)) = timeout(Duration::from_secs(30), health_check.0).await
else {
let _ = child.kill();
return Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
};
println!("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
}
};
if let Some(cli_health_check) = cli_health_check {
if sqlite_enabled {
println!("Does sqlite file exist: {sqlite_exists}");
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()
);
let _ = init_tx.send(InitStep::SqliteWaiting);
while !sqlite_exists {
sleep(Duration::from_secs(1)).await;
sqlite_exists = sqlite_file_exists();
}
}
}
tokio::spawn(cli_health_check);
}
let _ = server_ready_rx.await;
}
})
.map_err(|_| ())
.shared();
let loading_window = if sqlite_enabled
&& timeout(Duration::from_secs(1), loading_task.clone())
.await
.is_err()
{
println!("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;
Some(loading_window)
} else {
MainWindow::create(&app).expect("Failed to create main window");
None
};
let _ = loading_task.await;
println!("Loading done, completing initialisation");
let _ = init_tx.send(InitStep::Done);
if loading_window.is_some() {
loading_window_complete.await;
println!("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();
// Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
#[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) {
eprintln!("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;
println!("Attempting server connection to custom url: {custom_url:?}");
if let Some(url) = custom_url
&& server::check_health_or_ask_retry(&app, &url).await
{
println!("Connected to custom server: {}", url);
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}");
println!("Checking health of server '{}'", local_url);
if server::check_health(&local_url, None).await {
println!("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");
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;
}
}