Vendor opencode source for docker build
This commit is contained in:
241
opencode/packages/desktop/src-tauri/src/cli.rs
Normal file
241
opencode/packages/desktop/src-tauri/src/cli.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
use tauri::{AppHandle, Manager, path::BaseDirectory};
|
||||
use tauri_plugin_shell::{
|
||||
ShellExt,
|
||||
process::{Command, CommandChild, CommandEvent},
|
||||
};
|
||||
|
||||
use crate::{LogState, constants::MAX_LOG_ENTRIES};
|
||||
|
||||
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||
const CLI_BINARY_NAME: &str = "opencode";
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub hostname: Option<String>,
|
||||
pub port: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct Config {
|
||||
pub server: Option<ServerConfig>,
|
||||
}
|
||||
|
||||
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}"))
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout.to_vec()).ok())
|
||||
.and_then(|s| serde_json::from_str::<Config>(&s).ok())
|
||||
}
|
||||
|
||||
fn get_cli_install_path() -> Option<std::path::PathBuf> {
|
||||
std::env::var("HOME").ok().map(|home| {
|
||||
std::path::PathBuf::from(home)
|
||||
.join(CLI_INSTALL_DIR)
|
||||
.join(CLI_BINARY_NAME)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf {
|
||||
// Get binary with symlinks support
|
||||
tauri::process::current_binary(&app.env())
|
||||
.expect("Failed to get current binary")
|
||||
.parent()
|
||||
.expect("Failed to get parent dir")
|
||||
.join("opencode-cli")
|
||||
}
|
||||
|
||||
fn is_cli_installed() -> bool {
|
||||
get_cli_install_path()
|
||||
.map(|path| path.exists())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
const INSTALL_SCRIPT: &str = include_str!("../../../../install");
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
|
||||
if cfg!(not(unix)) {
|
||||
return Err("CLI installation is only supported on macOS & Linux".to_string());
|
||||
}
|
||||
|
||||
let sidecar = get_sidecar_path(&app);
|
||||
if !sidecar.exists() {
|
||||
return Err("Sidecar binary not found".to_string());
|
||||
}
|
||||
|
||||
let temp_script = std::env::temp_dir().join("opencode-install.sh");
|
||||
std::fs::write(&temp_script, INSTALL_SCRIPT)
|
||||
.map_err(|e| format!("Failed to write install script: {}", e))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&temp_script, std::fs::Permissions::from_mode(0o755))
|
||||
.map_err(|e| format!("Failed to set script permissions: {}", e))?;
|
||||
}
|
||||
|
||||
let output = std::process::Command::new(&temp_script)
|
||||
.arg("--binary")
|
||||
.arg(&sidecar)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run install script: {}", e))?;
|
||||
|
||||
let _ = std::fs::remove_file(&temp_script);
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Install script failed: {}", stderr));
|
||||
}
|
||||
|
||||
let install_path =
|
||||
get_cli_install_path().ok_or_else(|| "Could not determine install path".to_string())?;
|
||||
|
||||
Ok(install_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if cfg!(debug_assertions) {
|
||||
println!("Skipping CLI sync for debug build");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !is_cli_installed() {
|
||||
println!("No CLI installation found, skipping sync");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let cli_path =
|
||||
get_cli_install_path().ok_or_else(|| "Could not determine CLI install path".to_string())?;
|
||||
|
||||
let output = std::process::Command::new(&cli_path)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to get CLI version: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("Failed to get CLI version".to_string());
|
||||
}
|
||||
|
||||
let cli_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let cli_version = semver::Version::parse(&cli_version_str)
|
||||
.map_err(|e| format!("Failed to parse CLI version '{}': {}", cli_version_str, e))?;
|
||||
|
||||
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
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!(
|
||||
"CLI version {} is older than app version {}, syncing",
|
||||
cli_version, app_version
|
||||
);
|
||||
|
||||
install_cli(app)?;
|
||||
|
||||
println!("Synced installed CLI");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_user_shell() -> String {
|
||||
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
|
||||
}
|
||||
|
||||
pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
|
||||
let state_dir = app
|
||||
.path()
|
||||
.resolve("", BaseDirectory::AppLocalData)
|
||||
.expect("Failed to resolve app local data dir");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return app
|
||||
.shell()
|
||||
.sidecar("opencode-cli")
|
||||
.unwrap()
|
||||
.args(args.split_whitespace())
|
||||
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
|
||||
.env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true")
|
||||
.env("OPENCODE_CLIENT", "desktop")
|
||||
.env("XDG_STATE_HOME", &state_dir);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
return {
|
||||
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)
|
||||
};
|
||||
|
||||
app.shell()
|
||||
.command(&shell)
|
||||
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
|
||||
.env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true")
|
||||
.env("OPENCODE_CLIENT", "desktop")
|
||||
.env("XDG_STATE_HOME", &state_dir)
|
||||
.args(["-il", "-c", &cmd])
|
||||
};
|
||||
}
|
||||
|
||||
pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> CommandChild {
|
||||
let log_state = app.state::<LogState>();
|
||||
let log_state_clone = log_state.inner().clone();
|
||||
|
||||
println!("spawning sidecar on port {port}");
|
||||
|
||||
let (mut rx, child) = create_command(
|
||||
app,
|
||||
format!("serve --hostname {hostname} --port {port}").as_str(),
|
||||
)
|
||||
.env("OPENCODE_SERVER_USERNAME", "opencode")
|
||||
.env("OPENCODE_SERVER_PASSWORD", password)
|
||||
.spawn()
|
||||
.expect("Failed to spawn opencode");
|
||||
|
||||
tokio::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
|
||||
}
|
||||
10
opencode/packages/desktop/src-tauri/src/constants.rs
Normal file
10
opencode/packages/desktop/src-tauri/src/constants.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use tauri_plugin_window_state::StateFlags;
|
||||
|
||||
pub const SETTINGS_STORE: &str = "opencode.settings.dat";
|
||||
pub const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
|
||||
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
|
||||
}
|
||||
145
opencode/packages/desktop/src-tauri/src/job_object.rs
Normal file
145
opencode/packages/desktop/src-tauri/src/job_object.rs
Normal file
@@ -0,0 +1,145 @@
|
||||
//! Windows Job Object for reliable child process cleanup.
|
||||
//!
|
||||
//! This module provides a wrapper around Windows Job Objects with the
|
||||
//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` flag set. When the job object handle
|
||||
//! is closed (including when the parent process exits or crashes), Windows
|
||||
//! automatically terminates all processes assigned to the job.
|
||||
//!
|
||||
//! This is more reliable than manual cleanup because it works even if:
|
||||
//! - The parent process crashes
|
||||
//! - The parent is killed via Task Manager
|
||||
//! - The RunEvent::Exit handler fails to run
|
||||
|
||||
use std::io::{Error, Result};
|
||||
#[cfg(windows)]
|
||||
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,
|
||||
};
|
||||
use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
|
||||
|
||||
/// A Windows Job Object configured to kill all assigned processes when closed.
|
||||
///
|
||||
/// When this struct is dropped or when the owning process exits (even abnormally),
|
||||
/// Windows will automatically terminate all processes that have been assigned to it.
|
||||
pub struct JobObject(HANDLE);
|
||||
|
||||
// SAFETY: HANDLE is just a pointer-sized value, and Windows job objects
|
||||
// can be safely accessed from multiple threads.
|
||||
unsafe impl Send for JobObject {}
|
||||
unsafe impl Sync for JobObject {}
|
||||
|
||||
impl JobObject {
|
||||
/// Creates a new anonymous job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set.
|
||||
///
|
||||
/// When the last handle to this job is closed (including on process exit),
|
||||
/// Windows will terminate all processes assigned to the job.
|
||||
pub fn new() -> Result<Self> {
|
||||
unsafe {
|
||||
// Create an anonymous job object
|
||||
let job = CreateJobObjectW(None, None).map_err(|e| Error::other(e.message()))?;
|
||||
|
||||
// Configure the job to kill all processes when the handle is closed
|
||||
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&info as *const _ as *const std::ffi::c_void,
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
.map_err(|e| Error::other(e.message()))?;
|
||||
|
||||
Ok(Self(job))
|
||||
}
|
||||
}
|
||||
|
||||
/// Assigns a process to this job object by its process ID.
|
||||
///
|
||||
/// Once assigned, the process will be terminated when this job object is dropped
|
||||
/// or when the owning process exits.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `pid` - The process ID of the process to assign
|
||||
pub fn assign_pid(&self, pid: u32) -> Result<()> {
|
||||
unsafe {
|
||||
// Open a handle to the process with the minimum required permissions
|
||||
// PROCESS_SET_QUOTA and PROCESS_TERMINATE are required by AssignProcessToJobObject
|
||||
let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid)
|
||||
.map_err(|e| Error::other(e.message()))?;
|
||||
|
||||
// Assign the process to the job
|
||||
let result = AssignProcessToJobObject(self.0, process);
|
||||
|
||||
// Close our handle to the process - the job object maintains its own reference
|
||||
let _ = CloseHandle(process);
|
||||
|
||||
result.map_err(|e| Error::other(e.message()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for JobObject {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
// When this handle is closed and it's the last handle to the job,
|
||||
// Windows will terminate all processes in the job due to KILL_ON_JOB_CLOSE
|
||||
let _ = CloseHandle(self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds the Windows Job Object that ensures child processes are killed when the app exits.
|
||||
/// On Windows, when the job object handle is closed (including on crash), all assigned
|
||||
/// processes are automatically terminated by the OS.
|
||||
#[cfg(windows)]
|
||||
pub struct JobObjectState {
|
||||
job: Mutex<Option<JobObject>>,
|
||||
error: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl JobObjectState {
|
||||
pub fn new() -> Self {
|
||||
match JobObject::new() {
|
||||
Ok(job) => Self {
|
||||
job: Mutex::new(Some(job)),
|
||||
error: Mutex::new(None),
|
||||
},
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create job object: {e}");
|
||||
Self {
|
||||
job: Mutex::new(None),
|
||||
error: Mutex::new(Some(format!("Failed to create job object: {e}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}");
|
||||
*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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_job_object_creation() {
|
||||
let job = JobObject::new();
|
||||
assert!(job.is_ok(), "Failed to create job object: {:?}", job.err());
|
||||
}
|
||||
}
|
||||
541
opencode/packages/desktop/src-tauri/src/lib.rs
Normal file
541
opencode/packages/desktop/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,541 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
90
opencode/packages/desktop/src-tauri/src/main.rs
Normal file
90
opencode/packages/desktop/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
// 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() {
|
||||
// Ensure loopback connections are never sent through proxy settings.
|
||||
// Some VPNs/proxies set HTTP_PROXY/HTTPS_PROXY/ALL_PROXY without excluding localhost.
|
||||
const LOOPBACK: [&str; 3] = ["127.0.0.1", "localhost", "::1"];
|
||||
|
||||
let upsert = |key: &str| {
|
||||
let mut items = std::env::var(key)
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.map(|v| v.trim())
|
||||
.filter(|v| !v.is_empty())
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for host in LOOPBACK {
|
||||
if items.iter().any(|v| v.eq_ignore_ascii_case(host)) {
|
||||
continue;
|
||||
}
|
||||
items.push(host.to_string());
|
||||
}
|
||||
|
||||
// Safety: called during startup before any threads are spawned.
|
||||
unsafe { std::env::set_var(key, items.join(",")) };
|
||||
};
|
||||
|
||||
upsert("NO_PROXY");
|
||||
upsert("no_proxy");
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Some(backend_note) = configure_display_backend() {
|
||||
eprintln!("{backend_note:?}");
|
||||
}
|
||||
}
|
||||
|
||||
opencode_lib::run()
|
||||
}
|
||||
63
opencode/packages/desktop/src-tauri/src/markdown.rs
Normal file
63
opencode/packages/desktop/src-tauri/src/markdown.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use comrak::{
|
||||
Arena, Options, create_formatter, html::ChildRendering, nodes::NodeValue, parse_document,
|
||||
};
|
||||
use std::fmt::Write;
|
||||
|
||||
create_formatter!(ExternalLinkFormatter, {
|
||||
NodeValue::Link(ref nl) => |context, node, entering| {
|
||||
let skip = context.options.parse.relaxed_autolinks
|
||||
&& node.parent().is_some_and(|p| comrak::node_matches!(p, NodeValue::Link(..)));
|
||||
if skip {
|
||||
return Ok(ChildRendering::HTML);
|
||||
}
|
||||
|
||||
if entering {
|
||||
context.write_str("<a")?;
|
||||
comrak::html::render_sourcepos(context, node)?;
|
||||
|
||||
context.write_str(" href=\"")?;
|
||||
let url = &nl.url;
|
||||
if context.options.render.r#unsafe || !comrak::html::dangerous_url(url) {
|
||||
if let Some(rewriter) = &context.options.extension.link_url_rewriter {
|
||||
context.escape_href(&rewriter.to_html(url))?;
|
||||
} else {
|
||||
context.escape_href(url)?;
|
||||
}
|
||||
}
|
||||
context.write_str("\"")?;
|
||||
|
||||
if !nl.title.is_empty() {
|
||||
context.write_str(" title=\"")?;
|
||||
context.escape(&nl.title)?;
|
||||
context.write_str("\"")?;
|
||||
}
|
||||
|
||||
context.write_str(
|
||||
" class=\"external-link\" target=\"_blank\" rel=\"noopener noreferrer\">",
|
||||
)?;
|
||||
} else {
|
||||
context.write_str("</a>")?;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pub fn parse_markdown(input: &str) -> String {
|
||||
let mut options = Options::default();
|
||||
options.extension.strikethrough = true;
|
||||
options.extension.table = true;
|
||||
options.extension.tasklist = true;
|
||||
options.extension.autolink = true;
|
||||
options.render.r#unsafe = true;
|
||||
|
||||
let arena = Arena::new();
|
||||
let doc = parse_document(&arena, input, &options);
|
||||
let mut html = String::new();
|
||||
ExternalLinkFormatter::format_document(doc, &options, &mut html).unwrap_or_default();
|
||||
html
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn parse_markdown_command(markdown: String) -> Result<String, String> {
|
||||
Ok(parse_markdown(&markdown))
|
||||
}
|
||||
195
opencode/packages/desktop/src-tauri/src/server.rs
Normal file
195
opencode/packages/desktop/src-tauri/src/server.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
|
||||
use tauri_plugin_shell::process::CommandChild;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::{
|
||||
cli,
|
||||
constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE},
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
.map_err(|e| format!("Failed to open settings store: {}", e))?;
|
||||
|
||||
let value = store.get(DEFAULT_SERVER_URL_KEY);
|
||||
match value {
|
||||
Some(v) => Ok(v.as_str().map(String::from)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
.map_err(|e| format!("Failed to open settings store: {}", e))?;
|
||||
|
||||
match url {
|
||||
Some(u) => {
|
||||
store.set(DEFAULT_SERVER_URL_KEY, serde_json::Value::String(u));
|
||||
}
|
||||
None => {
|
||||
store.delete(DEFAULT_SERVER_URL_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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}");
|
||||
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}");
|
||||
return Some(url);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn spawn_local_server(
|
||||
app: AppHandle,
|
||||
hostname: String,
|
||||
port: u32,
|
||||
password: String,
|
||||
) -> (CommandChild, HealthCheck) {
|
||||
let child = cli::serve(&app, &hostname, port, &password);
|
||||
|
||||
let health_check = HealthCheck(tokio::spawn(async move {
|
||||
let url = format!("http://{hostname}:{port}");
|
||||
|
||||
let timestamp = Instant::now();
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
if check_health(&url, Some(&password)).await {
|
||||
println!("Server ready after {:?}", timestamp.elapsed());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
(child, health_check)
|
||||
}
|
||||
|
||||
pub struct HealthCheck(pub JoinHandle<()>);
|
||||
|
||||
pub async fn check_health(url: &str, password: Option<&str>) -> bool {
|
||||
let Ok(url) = reqwest::Url::parse(url) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(3));
|
||||
|
||||
if url_is_localhost(&url) {
|
||||
// Some environments set proxy variables (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) without
|
||||
// excluding loopback. reqwest respects these by default, which can prevent the desktop
|
||||
// app from reaching its own local sidecar server.
|
||||
builder = builder.no_proxy();
|
||||
};
|
||||
|
||||
let Ok(client) = builder.build() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(health_url) = url.join("/global/health") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut req = client.get(health_url);
|
||||
|
||||
if let Some(password) = password {
|
||||
req = req.basic_auth("opencode", Some(password));
|
||||
}
|
||||
|
||||
req.send()
|
||||
.await
|
||||
.map(|r| r.status().is_success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn url_is_localhost(url: &reqwest::Url) -> bool {
|
||||
url.host_str().is_some_and(|host| {
|
||||
host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|ip| ip.is_loopback())
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts a bind address hostname to a valid URL hostname for connection.
|
||||
/// - `0.0.0.0` and `::` are wildcard bind addresses, not valid connect targets
|
||||
/// - IPv6 addresses need brackets in URLs (e.g., `::1` -> `[::1]`)
|
||||
fn normalize_hostname_for_url(hostname: &str) -> String {
|
||||
// Wildcard bind addresses -> localhost equivalents
|
||||
if hostname == "0.0.0.0" {
|
||||
return "127.0.0.1".to_string();
|
||||
}
|
||||
if hostname == "::" {
|
||||
return "[::1]".to_string();
|
||||
}
|
||||
|
||||
// IPv6 addresses need brackets in URLs
|
||||
if hostname.contains(':') && !hostname.starts_with('[') {
|
||||
return format!("[{}]", hostname);
|
||||
}
|
||||
|
||||
hostname.to_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}");
|
||||
let hostname = server
|
||||
.hostname
|
||||
.as_ref()
|
||||
.map(|v| normalize_hostname_for_url(v))
|
||||
.unwrap_or_else(|| "127.0.0.1".to_string());
|
||||
|
||||
Some(format!("http://{}:{}", hostname, port))
|
||||
}
|
||||
|
||||
pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool {
|
||||
println!("Checking health for {url}");
|
||||
loop {
|
||||
if check_health(url, None).await {
|
||||
return true;
|
||||
}
|
||||
|
||||
const RETRY: &str = "Retry";
|
||||
|
||||
let res = app.dialog()
|
||||
.message(format!("Could not connect to configured server:\n{}\n\nWould you like to retry or start a local server instead?", url))
|
||||
.title("Connection Failed")
|
||||
.buttons(MessageDialogButtons::OkCancelCustom(RETRY.to_string(), "Start Local".to_string()))
|
||||
.blocking_show_with_result();
|
||||
|
||||
match res {
|
||||
MessageDialogResult::Custom(name) if name == RETRY => {
|
||||
continue;
|
||||
}
|
||||
_ => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
46
opencode/packages/desktop/src-tauri/src/window_customizer.rs
Normal file
46
opencode/packages/desktop/src-tauri/src/window_customizer.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
use tauri::{Manager, Runtime, Window, plugin::Plugin};
|
||||
|
||||
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::GestureZoom;
|
||||
use gtk::glib::ObjectExt;
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
unsafe {
|
||||
use objc2::rc::Retained;
|
||||
use objc2_web_kit::WKWebView;
|
||||
|
||||
// Get the WKWebView pointer and disable magnification gestures
|
||||
// This prevents Cmd+Ctrl+scroll and pinch-to-zoom from changing the zoom level
|
||||
let wk_webview: Retained<WKWebView> =
|
||||
Retained::retain(_webview.inner().cast()).unwrap();
|
||||
wk_webview.setAllowsMagnification(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
140
opencode/packages/desktop/src-tauri/src/windows.rs
Normal file
140
opencode/packages/desktop/src-tauri/src/windows.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use crate::constants::{UPDATER_ENABLED, window_state_flags};
|
||||
use std::{ops::Deref, time::Duration};
|
||||
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||
use tauri_plugin_window_state::AppHandleExt;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub struct MainWindow(WebviewWindow);
|
||||
|
||||
impl Deref for MainWindow {
|
||||
type Target = WebviewWindow;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl MainWindow {
|
||||
pub const LABEL: &str = "main";
|
||||
|
||||
pub fn create(app: &AppHandle) -> Result<Self, tauri::Error> {
|
||||
if let Some(window) = app.get_webview_window(Self::LABEL) {
|
||||
return Ok(Self(window));
|
||||
}
|
||||
|
||||
let window_builder = base_window_config(
|
||||
WebviewWindowBuilder::new(app, Self::LABEL, WebviewUrl::App("/".into())),
|
||||
app,
|
||||
)
|
||||
.title("OpenCode")
|
||||
.decorations(true)
|
||||
.disable_drag_drop_handler()
|
||||
.zoom_hotkeys_enabled(false)
|
||||
.visible(true)
|
||||
.maximized(true)
|
||||
.initialization_script(format!(
|
||||
r#"
|
||||
window.__OPENCODE__ ??= {{}};
|
||||
window.__OPENCODE__.updaterEnabled = {UPDATER_ENABLED};
|
||||
"#
|
||||
));
|
||||
|
||||
let window = window_builder.build()?;
|
||||
|
||||
setup_window_state_listener(app, &window);
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use tauri_plugin_decorum::WebviewWindowExt;
|
||||
let _ = window.create_overlay_titlebar();
|
||||
}
|
||||
|
||||
Ok(Self(window))
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_window_state_listener(app: &AppHandle, window: &WebviewWindow) {
|
||||
let (tx, mut rx) = mpsc::channel::<()>(1);
|
||||
|
||||
window.on_window_event(move |event| {
|
||||
use tauri::WindowEvent;
|
||||
if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) {
|
||||
return;
|
||||
}
|
||||
let _ = tx.try_send(());
|
||||
});
|
||||
|
||||
tokio::spawn({
|
||||
let app = app.clone();
|
||||
|
||||
async move {
|
||||
let save = || {
|
||||
let handle = app.clone();
|
||||
let app = app.clone();
|
||||
let _ = handle.run_on_main_thread(move || {
|
||||
let _ = app.save_window_state(window_state_flags());
|
||||
});
|
||||
};
|
||||
|
||||
while rx.recv().await.is_some() {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
|
||||
save();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub struct LoadingWindow(WebviewWindow);
|
||||
|
||||
impl Deref for LoadingWindow {
|
||||
type Target = WebviewWindow;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl LoadingWindow {
|
||||
pub const LABEL: &str = "loading";
|
||||
|
||||
pub fn create(app: &AppHandle) -> Result<Self, tauri::Error> {
|
||||
let window_builder = base_window_config(
|
||||
WebviewWindowBuilder::new(app, Self::LABEL, tauri::WebviewUrl::App("/loading".into())),
|
||||
app,
|
||||
)
|
||||
.center()
|
||||
.resizable(false)
|
||||
.inner_size(640.0, 480.0)
|
||||
.visible(true);
|
||||
|
||||
Ok(Self(window_builder.build()?))
|
||||
}
|
||||
}
|
||||
|
||||
fn base_window_config<'a, R: Runtime, M: Manager<R>>(
|
||||
window_builder: WebviewWindowBuilder<'a, R, M>,
|
||||
_app: &AppHandle,
|
||||
) -> WebviewWindowBuilder<'a, R, M> {
|
||||
let window_builder = window_builder.decorations(true);
|
||||
|
||||
#[cfg(windows)]
|
||||
let window_builder = window_builder
|
||||
// Some VPNs set a global/system proxy that WebView2 applies even for loopback
|
||||
// connections, which breaks the app's localhost sidecar server.
|
||||
// Note: when setting additional args, we must re-apply wry's default
|
||||
// `--disable-features=...` flags.
|
||||
.additional_browser_args(
|
||||
"--proxy-bypass-list=<-loopback> --disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection",
|
||||
)
|
||||
.data_directory(_app.path().config_dir().expect("Failed to get config dir").join(_app.config().product_name.clone().unwrap()))
|
||||
.decorations(false);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let window_builder = window_builder
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||
.hidden_title(true)
|
||||
.traffic_light_position(tauri::LogicalPosition::new(12.0, 18.0));
|
||||
|
||||
window_builder
|
||||
}
|
||||
Reference in New Issue
Block a user