chore: initialize qiming workspace repository
This commit is contained in:
742
qimingcode/packages/desktop/src-tauri/src/cli.rs
Normal file
742
qimingcode/packages/desktop/src-tauri/src/cli.rs
Normal file
@@ -0,0 +1,742 @@
|
||||
use futures::{FutureExt, Stream, StreamExt, future};
|
||||
use process_wrap::tokio::CommandWrap;
|
||||
#[cfg(unix)]
|
||||
use process_wrap::tokio::ProcessGroup;
|
||||
#[cfg(windows)]
|
||||
use process_wrap::tokio::{CommandWrapper, JobObject, KillOnDrop};
|
||||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Manager, path::BaseDirectory};
|
||||
use tauri_specta::Event;
|
||||
use tokio::{
|
||||
io::{AsyncBufRead, AsyncBufReadExt, BufReader},
|
||||
process::Command,
|
||||
sync::{mpsc, oneshot},
|
||||
task::JoinHandle,
|
||||
};
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tracing::Instrument;
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED};
|
||||
|
||||
use crate::server::get_wsl_config;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
// Keep this as a custom wrapper instead of process_wrap::CreationFlags.
|
||||
// JobObject pre_spawn rewrites creation flags, so this must run after it.
|
||||
struct WinCreationFlags;
|
||||
|
||||
#[cfg(windows)]
|
||||
impl CommandWrapper for WinCreationFlags {
|
||||
fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> std::io::Result<()> {
|
||||
command.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const CLI_INSTALL_DIR: &str = ".opencode/bin";
|
||||
const CLI_BINARY_NAME: &str = "opencode";
|
||||
const SHELL_ENV_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
pub struct ServerConfig {
|
||||
pub hostname: Option<String>,
|
||||
pub port: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
pub struct Config {
|
||||
pub server: Option<ServerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CommandEvent {
|
||||
Stdout(String),
|
||||
Stderr(String),
|
||||
Error(String),
|
||||
Terminated(TerminatedPayload),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TerminatedPayload {
|
||||
pub code: Option<i32>,
|
||||
pub signal: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CommandChild {
|
||||
kill: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl CommandChild {
|
||||
pub fn kill(&self) -> std::io::Result<()> {
|
||||
self.kill
|
||||
.try_send(())
|
||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_config(app: &AppHandle) -> Option<Config> {
|
||||
let (events, _) = spawn_command(app, "debug config", &[]).ok()?;
|
||||
|
||||
events
|
||||
.fold(String::new(), async |mut config_str, event| {
|
||||
if let CommandEvent::Stdout(s) = &event {
|
||||
config_str += s.as_str()
|
||||
}
|
||||
if let CommandEvent::Stderr(s) = &event {
|
||||
config_str += s.as_str()
|
||||
}
|
||||
|
||||
config_str
|
||||
})
|
||||
.map(|v| serde_json::from_str::<Config>(&v))
|
||||
.await
|
||||
.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) {
|
||||
tracing::debug!("Skipping CLI sync for debug build");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if !is_cli_installed() {
|
||||
tracing::info!("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 {
|
||||
tracing::info!(
|
||||
%cli_version, %app_version,
|
||||
"CLI is up to date, skipping sync"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
%cli_version, %app_version,
|
||||
"CLI is older than app version, syncing"
|
||||
);
|
||||
|
||||
install_cli(app)?;
|
||||
|
||||
tracing::info!("Synced installed CLI");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_user_shell() -> String {
|
||||
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
|
||||
}
|
||||
|
||||
fn is_wsl_enabled(_app: &tauri::AppHandle) -> bool {
|
||||
get_wsl_config(_app.clone()).is_ok_and(|v| v.enabled)
|
||||
}
|
||||
|
||||
fn shell_escape(input: &str) -> String {
|
||||
if input.is_empty() {
|
||||
return "''".to_string();
|
||||
}
|
||||
|
||||
let mut escaped = String::from("'");
|
||||
escaped.push_str(&input.replace("'", "'\"'\"'"));
|
||||
escaped.push('\'');
|
||||
escaped
|
||||
}
|
||||
|
||||
fn parse_shell_env(stdout: &[u8]) -> HashMap<String, String> {
|
||||
String::from_utf8_lossy(stdout)
|
||||
.split('\0')
|
||||
.filter_map(|line| {
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (key, value) = line.split_once('=')?;
|
||||
if key.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((key.to_string(), value.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn command_output_with_timeout(
|
||||
mut cmd: std::process::Command,
|
||||
timeout: Duration,
|
||||
) -> std::io::Result<Option<std::process::Output>> {
|
||||
let mut child = cmd.spawn()?;
|
||||
let start = Instant::now();
|
||||
|
||||
loop {
|
||||
if child.try_wait()?.is_some() {
|
||||
return child.wait_with_output().map(Some);
|
||||
}
|
||||
|
||||
if start.elapsed() >= timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
enum ShellEnvProbe {
|
||||
Loaded(HashMap<String, String>),
|
||||
Timeout,
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
fn probe_shell_env(shell: &str, mode: &str) -> ShellEnvProbe {
|
||||
let mut cmd = std::process::Command::new(shell);
|
||||
cmd.args([mode, "-c", "env -0"]);
|
||||
cmd.stdin(Stdio::null());
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::null());
|
||||
let output = match command_output_with_timeout(cmd, SHELL_ENV_TIMEOUT) {
|
||||
Ok(Some(output)) => output,
|
||||
Ok(None) => return ShellEnvProbe::Timeout,
|
||||
Err(error) => {
|
||||
tracing::debug!(shell, mode, ?error, "Shell env probe failed");
|
||||
return ShellEnvProbe::Unavailable;
|
||||
}
|
||||
};
|
||||
if !output.status.success() {
|
||||
tracing::debug!(shell, mode, "Shell env probe exited with non-zero status");
|
||||
return ShellEnvProbe::Unavailable;
|
||||
}
|
||||
let env = parse_shell_env(&output.stdout);
|
||||
if env.is_empty() {
|
||||
tracing::debug!(shell, mode, "Shell env probe returned empty env");
|
||||
return ShellEnvProbe::Unavailable;
|
||||
}
|
||||
|
||||
ShellEnvProbe::Loaded(env)
|
||||
}
|
||||
|
||||
fn is_nushell(shell: &str) -> bool {
|
||||
let shell_name = Path::new(shell)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(shell)
|
||||
.to_ascii_lowercase();
|
||||
shell_name == "nu" || shell_name == "nu.exe" || shell.to_ascii_lowercase().ends_with("\\nu.exe")
|
||||
}
|
||||
fn load_shell_env(shell: &str) -> Option<HashMap<String, String>> {
|
||||
if is_nushell(shell) {
|
||||
tracing::debug!(shell, "Skipping shell env probe for nushell");
|
||||
return None;
|
||||
}
|
||||
|
||||
match probe_shell_env(shell, "-il") {
|
||||
ShellEnvProbe::Loaded(env) => {
|
||||
tracing::info!(
|
||||
shell,
|
||||
env_count = env.len(),
|
||||
"Loaded shell environment with -il"
|
||||
);
|
||||
return Some(env);
|
||||
}
|
||||
ShellEnvProbe::Timeout => {
|
||||
tracing::warn!(shell, "Interactive shell env probe timed out");
|
||||
return None;
|
||||
}
|
||||
ShellEnvProbe::Unavailable => {}
|
||||
}
|
||||
|
||||
if let ShellEnvProbe::Loaded(env) = probe_shell_env(shell, "-l") {
|
||||
tracing::info!(
|
||||
shell,
|
||||
env_count = env.len(),
|
||||
"Loaded shell environment with -l"
|
||||
);
|
||||
return Some(env);
|
||||
}
|
||||
tracing::warn!(shell, "Falling back to app environment");
|
||||
None
|
||||
}
|
||||
|
||||
fn merge_shell_env(
|
||||
shell_env: Option<HashMap<String, String>>,
|
||||
envs: Vec<(String, String)>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut merged = shell_env.unwrap_or_default();
|
||||
for (key, value) in envs {
|
||||
merged.insert(key, value);
|
||||
}
|
||||
|
||||
merged.into_iter().collect()
|
||||
}
|
||||
|
||||
pub fn spawn_command(
|
||||
app: &tauri::AppHandle,
|
||||
args: &str,
|
||||
extra_env: &[(&str, String)],
|
||||
) -> Result<(impl Stream<Item = CommandEvent> + 'static, CommandChild), std::io::Error> {
|
||||
let state_dir = app
|
||||
.path()
|
||||
.resolve("", BaseDirectory::AppLocalData)
|
||||
.expect("Failed to resolve app local data dir");
|
||||
|
||||
let mut envs = vec![
|
||||
(
|
||||
"OPENCODE_EXPERIMENTAL_ICON_DISCOVERY".to_string(),
|
||||
"true".to_string(),
|
||||
),
|
||||
(
|
||||
"OPENCODE_EXPERIMENTAL_FILEWATCHER".to_string(),
|
||||
"true".to_string(),
|
||||
),
|
||||
("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
|
||||
(
|
||||
"XDG_STATE_HOME".to_string(),
|
||||
state_dir.to_string_lossy().to_string(),
|
||||
),
|
||||
];
|
||||
envs.extend(
|
||||
extra_env
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_string(), value.clone())),
|
||||
);
|
||||
|
||||
let mut cmd = if cfg!(windows) {
|
||||
if is_wsl_enabled(app) {
|
||||
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(),
|
||||
"BIN=\"$HOME/.opencode/bin/opencode\"".to_string(),
|
||||
"if [ ! -x \"$BIN\" ]; then".to_string(),
|
||||
format!(
|
||||
" curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path",
|
||||
shell_escape(&version)
|
||||
),
|
||||
"fi".to_string(),
|
||||
];
|
||||
|
||||
let mut env_prefix = vec![
|
||||
"OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true".to_string(),
|
||||
"OPENCODE_EXPERIMENTAL_FILEWATCHER=true".to_string(),
|
||||
"OPENCODE_CLIENT=desktop".to_string(),
|
||||
"XDG_STATE_HOME=\"$HOME/.local/state\"".to_string(),
|
||||
];
|
||||
env_prefix.extend(
|
||||
envs.iter()
|
||||
.filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
|
||||
.filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_FILEWATCHER")
|
||||
.filter(|(key, _)| key != "OPENCODE_CLIENT")
|
||||
.filter(|(key, _)| key != "XDG_STATE_HOME")
|
||||
.map(|(key, value)| format!("{}={}", key, shell_escape(value))),
|
||||
);
|
||||
|
||||
script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args));
|
||||
|
||||
let mut cmd = Command::new("wsl");
|
||||
cmd.args(["-e", "bash", "-lc", &script.join("\n")]);
|
||||
cmd
|
||||
} else {
|
||||
let sidecar = get_sidecar_path(app);
|
||||
let mut cmd = Command::new(sidecar);
|
||||
cmd.args(args.split_whitespace());
|
||||
|
||||
for (key, value) in envs {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
cmd
|
||||
}
|
||||
} else {
|
||||
let sidecar = get_sidecar_path(app);
|
||||
let shell = get_user_shell();
|
||||
let envs = merge_shell_env(load_shell_env(&shell), envs);
|
||||
|
||||
let line = if shell.ends_with("/nu") {
|
||||
format!("^\"{}\" {}", sidecar.display(), args)
|
||||
} else {
|
||||
format!("\"{}\" {}", sidecar.display(), args)
|
||||
};
|
||||
|
||||
let mut cmd = Command::new(shell);
|
||||
cmd.current_dir(app.path().home_dir().unwrap());
|
||||
cmd.args(["-l", "-c", &line]);
|
||||
|
||||
for (key, value) in envs {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
|
||||
cmd
|
||||
};
|
||||
|
||||
cmd.stdout(Stdio::piped());
|
||||
cmd.stderr(Stdio::piped());
|
||||
cmd.stdin(Stdio::null());
|
||||
|
||||
let mut wrap = CommandWrap::from(cmd);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
wrap.wrap(ProcessGroup::leader());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
wrap.wrap(JobObject).wrap(WinCreationFlags).wrap(KillOnDrop);
|
||||
}
|
||||
|
||||
let mut child = wrap.spawn()?;
|
||||
let guard = Arc::new(tokio::sync::RwLock::new(()));
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
let (kill_tx, mut kill_rx) = mpsc::channel(1);
|
||||
|
||||
let stdout = spawn_pipe_reader(
|
||||
tx.clone(),
|
||||
guard.clone(),
|
||||
BufReader::new(child.stdout().take().unwrap()),
|
||||
CommandEvent::Stdout,
|
||||
);
|
||||
let stderr = spawn_pipe_reader(
|
||||
tx.clone(),
|
||||
guard.clone(),
|
||||
BufReader::new(child.stderr().take().unwrap()),
|
||||
CommandEvent::Stderr,
|
||||
);
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
let mut kill_open = true;
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break Ok(status),
|
||||
Ok(None) => {}
|
||||
Err(err) => break Err(err),
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
msg = kill_rx.recv(), if kill_open => {
|
||||
if msg.is_some() {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
kill_open = false;
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
|
||||
}
|
||||
};
|
||||
|
||||
match status {
|
||||
Ok(status) => {
|
||||
let payload = TerminatedPayload {
|
||||
code: status.code(),
|
||||
signal: signal_from_status(status),
|
||||
};
|
||||
let _ = tx.send(CommandEvent::Terminated(payload)).await;
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = tx.send(CommandEvent::Error(err.to_string())).await;
|
||||
}
|
||||
}
|
||||
|
||||
stdout.abort();
|
||||
stderr.abort();
|
||||
});
|
||||
|
||||
let event_stream = ReceiverStream::new(rx);
|
||||
let event_stream = sqlite_migration::logs_middleware(app.clone(), event_stream);
|
||||
|
||||
Ok((event_stream, CommandChild { kill: kill_tx }))
|
||||
}
|
||||
|
||||
fn signal_from_status(status: std::process::ExitStatus) -> Option<i32> {
|
||||
#[cfg(unix)]
|
||||
return status.signal();
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = status;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serve(
|
||||
app: &AppHandle,
|
||||
hostname: &str,
|
||||
port: u32,
|
||||
password: &str,
|
||||
) -> (CommandChild, oneshot::Receiver<TerminatedPayload>) {
|
||||
let (exit_tx, exit_rx) = oneshot::channel::<TerminatedPayload>();
|
||||
|
||||
tracing::info!(port, "Spawning sidecar");
|
||||
|
||||
let envs = [
|
||||
("OPENCODE_SERVER_USERNAME", "opencode".to_string()),
|
||||
("OPENCODE_SERVER_PASSWORD", password.to_string()),
|
||||
];
|
||||
|
||||
let (events, child) = spawn_command(
|
||||
app,
|
||||
format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(),
|
||||
&envs,
|
||||
)
|
||||
.expect("Failed to spawn opencode");
|
||||
|
||||
let mut exit_tx = Some(exit_tx);
|
||||
tokio::spawn(
|
||||
events
|
||||
.for_each(move |event| {
|
||||
match event {
|
||||
CommandEvent::Stdout(line) => {
|
||||
tracing::info!("{line}");
|
||||
}
|
||||
CommandEvent::Stderr(line) => {
|
||||
tracing::info!("{line}");
|
||||
}
|
||||
CommandEvent::Error(err) => {
|
||||
tracing::error!("{err}");
|
||||
}
|
||||
CommandEvent::Terminated(payload) => {
|
||||
tracing::info!(
|
||||
code = ?payload.code,
|
||||
signal = ?payload.signal,
|
||||
"Sidecar terminated"
|
||||
);
|
||||
|
||||
if let Some(tx) = exit_tx.take() {
|
||||
let _ = tx.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
future::ready(())
|
||||
})
|
||||
.instrument(tracing::info_span!("sidecar")),
|
||||
);
|
||||
|
||||
(child, exit_rx)
|
||||
}
|
||||
|
||||
pub mod sqlite_migration {
|
||||
use super::*;
|
||||
|
||||
#[derive(
|
||||
tauri_specta::Event, serde::Serialize, serde::Deserialize, Clone, Copy, Debug, specta::Type,
|
||||
)]
|
||||
#[serde(tag = "type", content = "value")]
|
||||
pub enum SqliteMigrationProgress {
|
||||
InProgress(u8),
|
||||
Done,
|
||||
}
|
||||
|
||||
pub(super) fn logs_middleware(
|
||||
app: AppHandle,
|
||||
stream: impl Stream<Item = CommandEvent>,
|
||||
) -> impl Stream<Item = CommandEvent> {
|
||||
let app = app.clone();
|
||||
let mut done = false;
|
||||
|
||||
stream.filter_map(move |event| {
|
||||
if done {
|
||||
return future::ready(Some(event));
|
||||
}
|
||||
|
||||
future::ready(match &event {
|
||||
CommandEvent::Stdout(s) | CommandEvent::Stderr(s) => {
|
||||
if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) {
|
||||
if let Ok(progress) = s.parse::<u8>() {
|
||||
let _ = SqliteMigrationProgress::InProgress(progress).emit(&app);
|
||||
} else if s == "done" {
|
||||
done = true;
|
||||
let _ = SqliteMigrationProgress::Done.emit(&app);
|
||||
}
|
||||
|
||||
None
|
||||
} else {
|
||||
Some(event)
|
||||
}
|
||||
}
|
||||
_ => Some(event),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_pipe_reader<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
|
||||
tx: mpsc::Sender<CommandEvent>,
|
||||
guard: Arc<tokio::sync::RwLock<()>>,
|
||||
pipe_reader: impl AsyncBufRead + Send + Unpin + 'static,
|
||||
wrapper: F,
|
||||
) -> JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let _lock = guard.read().await;
|
||||
let reader = BufReader::new(pipe_reader);
|
||||
|
||||
read_line(reader, tx, wrapper).await;
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_line<F: Fn(String) -> CommandEvent + Send + Copy + 'static>(
|
||||
reader: BufReader<impl AsyncBufRead + Unpin>,
|
||||
tx: mpsc::Sender<CommandEvent>,
|
||||
wrapper: F,
|
||||
) {
|
||||
let mut lines = reader.lines();
|
||||
loop {
|
||||
let line = lines.next_line().await;
|
||||
|
||||
match line {
|
||||
Ok(s) => {
|
||||
if let Some(s) = s {
|
||||
let _ = tx.clone().send(wrapper(s)).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let tx_ = tx.clone();
|
||||
let _ = tx_.send(CommandEvent::Error(e.to_string())).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[test]
|
||||
fn parse_shell_env_supports_null_delimited_pairs() {
|
||||
let env = parse_shell_env(b"PATH=/usr/bin:/bin\0FOO=bar=baz\0\0");
|
||||
|
||||
assert_eq!(env.get("PATH"), Some(&"/usr/bin:/bin".to_string()));
|
||||
assert_eq!(env.get("FOO"), Some(&"bar=baz".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_shell_env_ignores_invalid_entries() {
|
||||
let env = parse_shell_env(b"INVALID\0=empty\0OK=1\0");
|
||||
|
||||
assert_eq!(env.len(), 1);
|
||||
assert_eq!(env.get("OK"), Some(&"1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_shell_env_keeps_explicit_overrides() {
|
||||
let mut shell_env = HashMap::new();
|
||||
shell_env.insert("PATH".to_string(), "/shell/path".to_string());
|
||||
shell_env.insert("HOME".to_string(), "/tmp/home".to_string());
|
||||
|
||||
let merged = merge_shell_env(
|
||||
Some(shell_env),
|
||||
vec![
|
||||
("PATH".to_string(), "/desktop/path".to_string()),
|
||||
("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
|
||||
],
|
||||
)
|
||||
.into_iter()
|
||||
.collect::<HashMap<_, _>>();
|
||||
|
||||
assert_eq!(merged.get("PATH"), Some(&"/desktop/path".to_string()));
|
||||
assert_eq!(merged.get("HOME"), Some(&"/tmp/home".to_string()));
|
||||
assert_eq!(merged.get("OPENCODE_CLIENT"), Some(&"desktop".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_nushell_handles_path_and_binary_name() {
|
||||
assert!(is_nushell("nu"));
|
||||
assert!(is_nushell("/opt/homebrew/bin/nu"));
|
||||
assert!(is_nushell("C:\\Program Files\\nu.exe"));
|
||||
assert!(!is_nushell("/bin/zsh"));
|
||||
}
|
||||
}
|
||||
10
qimingcode/packages/desktop/src-tauri/src/constants.rs
Normal file
10
qimingcode/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 WSL_ENABLED_KEY: &str = "wslEnabled";
|
||||
pub const UPDATER_ENABLED: bool = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
|
||||
|
||||
pub fn window_state_flags() -> StateFlags {
|
||||
StateFlags::all() - StateFlags::DECORATIONS - StateFlags::VISIBLE
|
||||
}
|
||||
601
qimingcode/packages/desktop/src-tauri/src/lib.rs
Normal file
601
qimingcode/packages/desktop/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,601 @@
|
||||
mod cli;
|
||||
mod constants;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux_display;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux_windowing;
|
||||
mod logging;
|
||||
mod markdown;
|
||||
mod os;
|
||||
mod server;
|
||||
mod window_customizer;
|
||||
mod windows;
|
||||
|
||||
use crate::cli::CommandChild;
|
||||
use futures::{FutureExt, TryFutureExt};
|
||||
use std::{
|
||||
env,
|
||||
future::Future,
|
||||
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_specta::Event;
|
||||
use tokio::{
|
||||
sync::{oneshot, watch},
|
||||
time::{sleep, timeout},
|
||||
};
|
||||
|
||||
use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli};
|
||||
use crate::constants::*;
|
||||
use crate::windows::{LoadingWindow, MainWindow};
|
||||
|
||||
#[derive(Clone, serde::Serialize, specta::Type, Debug)]
|
||||
struct ServerReadyData {
|
||||
url: String,
|
||||
username: Option<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>,
|
||||
}
|
||||
|
||||
struct ServerState {
|
||||
child: Arc<Mutex<Option<CommandChild>>>,
|
||||
}
|
||||
|
||||
/// Resolves with sidecar credentials as soon as the sidecar is spawned (before health check).
|
||||
struct SidecarReady(futures::future::Shared<oneshot::Receiver<ServerReadyData>>);
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
async fn await_initialization(
|
||||
state: State<'_, SidecarReady>,
|
||||
init_state: State<'_, InitState>,
|
||||
events: Channel<InitStep>,
|
||||
) -> Result<ServerReadyData, String> {
|
||||
let mut rx = init_state.current.clone();
|
||||
|
||||
let stream = 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for sidecar credentials (available immediately after spawn, before health check)
|
||||
let data = async {
|
||||
state
|
||||
.inner()
|
||||
.0
|
||||
.clone()
|
||||
.await
|
||||
.map_err(|_| "Failed to get sidecar data".to_string())
|
||||
};
|
||||
|
||||
let (result, _) = futures::future::join(data, stream).await;
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
fn check_app_exists(app_name: &str) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
fn resolve_app_path(app_name: &str) -> Option<String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
fn open_path(_app: AppHandle, path: String, app_name: Option<String>) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_name = app_name.map(|v| os::windows::resolve_windows_app_path(&v).unwrap_or(v));
|
||||
let is_powershell = app_name.as_ref().is_some_and(|v| {
|
||||
std::path::Path::new(v)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| {
|
||||
name.eq_ignore_ascii_case("powershell")
|
||||
|| name.eq_ignore_ascii_case("powershell.exe")
|
||||
})
|
||||
});
|
||||
|
||||
if is_powershell {
|
||||
return os::windows::open_in_powershell(path);
|
||||
}
|
||||
|
||||
return tauri_plugin_opener::open_path(path, app_name.as_deref())
|
||||
.map_err(|e| format!("Failed to open path: {e}"));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
tauri_plugin_opener::open_path(path, app_name.as_deref())
|
||||
.map_err(|e| format!("Failed to open path: {e}"))
|
||||
}
|
||||
|
||||
#[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,
|
||||
open_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());
|
||||
|
||||
// Spawn sidecar immediately - credentials are known before health check
|
||||
let port = get_sidecar_port();
|
||||
let hostname = "127.0.0.1";
|
||||
let url = format!("http://{hostname}:{port}");
|
||||
let password = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
tracing::info!("Spawning sidecar on {url}");
|
||||
let (child, health_check) =
|
||||
server::spawn_local_server(app.clone(), hostname.to_string(), port, password.clone());
|
||||
|
||||
// Make sidecar credentials available immediately (before health check completes)
|
||||
let (ready_tx, ready_rx) = oneshot::channel();
|
||||
let _ = ready_tx.send(ServerReadyData {
|
||||
url: url.clone(),
|
||||
username: Some("opencode".to_string()),
|
||||
password: Some(password),
|
||||
});
|
||||
app.manage(SidecarReady(ready_rx.shared()));
|
||||
app.manage(ServerState {
|
||||
child: Arc::new(Mutex::new(Some(child))),
|
||||
});
|
||||
|
||||
let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
|
||||
|
||||
// SQLite migration handling:
|
||||
// We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it.
|
||||
// A separate loading window is shown for long migrations.
|
||||
let needs_migration = !sqlite_file_exists();
|
||||
let sqlite_done = needs_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);
|
||||
}))
|
||||
});
|
||||
|
||||
// The loading task waits for SQLite migration (if needed) then for the sidecar health check.
|
||||
// This is only used to drive the loading window progress - the main window is shown immediately.
|
||||
let loading_task = tokio::spawn({
|
||||
async move {
|
||||
if let Some(sqlite_done_rx) = sqlite_done {
|
||||
let _ = sqlite_done_rx.await;
|
||||
}
|
||||
|
||||
// Wait for sidecar to become healthy (for loading window progress)
|
||||
let res = timeout(Duration::from_secs(30), health_check.0).await;
|
||||
match res {
|
||||
Ok(Ok(Ok(()))) => tracing::info!("Sidecar health check OK"),
|
||||
Ok(Ok(Err(e))) => tracing::error!("Sidecar health check failed: {e}"),
|
||||
Ok(Err(e)) => tracing::error!("Sidecar health check task failed: {e}"),
|
||||
Err(_) => tracing::error!("Sidecar health check timed out"),
|
||||
}
|
||||
|
||||
tracing::info!("Loading task finished");
|
||||
}
|
||||
})
|
||||
.map_err(|_| ())
|
||||
.shared();
|
||||
|
||||
// Show loading window for SQLite migrations if they take >1s
|
||||
let loading_window = if needs_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 {
|
||||
None
|
||||
};
|
||||
|
||||
// Create main window immediately - the web app handles its own loading/health gate
|
||||
MainWindow::create(&app).expect("Failed to create main window");
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
53
qimingcode/packages/desktop/src-tauri/src/linux_display.rs
Normal file
53
qimingcode/packages/desktop/src-tauri/src/linux_display.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
|
||||
use crate::constants::SETTINGS_STORE;
|
||||
|
||||
pub const LINUX_DISPLAY_CONFIG_KEY: &str = "linuxDisplayConfig";
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct DisplayConfig {
|
||||
wayland: Option<bool>,
|
||||
}
|
||||
|
||||
fn dir() -> Option<PathBuf> {
|
||||
Some(dirs::data_dir()?.join(if cfg!(debug_assertions) {
|
||||
"ai.opencode.desktop.dev"
|
||||
} else {
|
||||
"ai.opencode.desktop"
|
||||
}))
|
||||
}
|
||||
|
||||
fn path() -> Option<PathBuf> {
|
||||
dir().map(|dir| dir.join(SETTINGS_STORE))
|
||||
}
|
||||
|
||||
pub fn read_wayland() -> Option<bool> {
|
||||
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
|
||||
}
|
||||
|
||||
pub fn write_wayland(app: &AppHandle, value: bool) -> Result<(), String> {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
.map_err(|e| format!("Failed to open settings store: {}", e))?;
|
||||
|
||||
store.set(
|
||||
LINUX_DISPLAY_CONFIG_KEY,
|
||||
json!(DisplayConfig {
|
||||
wayland: Some(value),
|
||||
}),
|
||||
);
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save settings store: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
475
qimingcode/packages/desktop/src-tauri/src/linux_windowing.rs
Normal file
475
qimingcode/packages/desktop/src-tauri/src/linux_windowing.rs
Normal file
@@ -0,0 +1,475 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Backend {
|
||||
Auto,
|
||||
Wayland,
|
||||
X11,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BackendDecision {
|
||||
pub backend: Backend,
|
||||
pub note: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SessionEnv {
|
||||
pub wayland_display: bool,
|
||||
pub xdg_session_type: Option<String>,
|
||||
pub display: bool,
|
||||
pub xdg_current_desktop: Option<String>,
|
||||
pub xdg_session_desktop: Option<String>,
|
||||
pub desktop_session: Option<String>,
|
||||
pub oc_allow_wayland: Option<String>,
|
||||
pub oc_force_x11: Option<String>,
|
||||
pub oc_force_wayland: Option<String>,
|
||||
pub oc_linux_decorations: Option<String>,
|
||||
pub oc_force_decorations: Option<String>,
|
||||
pub oc_no_decorations: Option<String>,
|
||||
pub i3_sock: bool,
|
||||
}
|
||||
|
||||
impl SessionEnv {
|
||||
pub fn capture() -> Self {
|
||||
Self {
|
||||
wayland_display: std::env::var_os("WAYLAND_DISPLAY").is_some(),
|
||||
xdg_session_type: std::env::var("XDG_SESSION_TYPE").ok(),
|
||||
display: std::env::var_os("DISPLAY").is_some(),
|
||||
xdg_current_desktop: std::env::var("XDG_CURRENT_DESKTOP").ok(),
|
||||
xdg_session_desktop: std::env::var("XDG_SESSION_DESKTOP").ok(),
|
||||
desktop_session: std::env::var("DESKTOP_SESSION").ok(),
|
||||
oc_allow_wayland: std::env::var("OC_ALLOW_WAYLAND").ok(),
|
||||
oc_force_x11: std::env::var("OC_FORCE_X11").ok(),
|
||||
oc_force_wayland: std::env::var("OC_FORCE_WAYLAND").ok(),
|
||||
oc_linux_decorations: std::env::var("OC_LINUX_DECORATIONS").ok(),
|
||||
oc_force_decorations: std::env::var("OC_FORCE_DECORATIONS").ok(),
|
||||
oc_no_decorations: std::env::var("OC_NO_DECORATIONS").ok(),
|
||||
i3_sock: std::env::var_os("I3SOCK").is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn select_backend(env: &SessionEnv, prefer_wayland: bool) -> Option<BackendDecision> {
|
||||
if is_truthy(env.oc_force_x11.as_deref()) {
|
||||
return Some(BackendDecision {
|
||||
backend: Backend::X11,
|
||||
note: "Forcing X11 due to OC_FORCE_X11=1".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if is_truthy(env.oc_force_wayland.as_deref()) {
|
||||
return Some(BackendDecision {
|
||||
backend: Backend::Wayland,
|
||||
note: "Forcing native Wayland due to OC_FORCE_WAYLAND=1".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if !is_wayland_session(env) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if prefer_wayland {
|
||||
return Some(BackendDecision {
|
||||
backend: Backend::Wayland,
|
||||
note: "Wayland session detected; forcing native Wayland from settings".into(),
|
||||
});
|
||||
}
|
||||
|
||||
if is_truthy(env.oc_allow_wayland.as_deref()) {
|
||||
return Some(BackendDecision {
|
||||
backend: Backend::Wayland,
|
||||
note: "Wayland session detected; forcing native Wayland due to OC_ALLOW_WAYLAND=1"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
|
||||
Some(BackendDecision {
|
||||
backend: Backend::Auto,
|
||||
note: "Wayland session detected; using native Wayland first with X11 fallback (auto backend). Set OC_FORCE_X11=1 to force X11."
|
||||
.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn use_decorations(env: &SessionEnv) -> bool {
|
||||
if let Some(mode) = decoration_override(env.oc_linux_decorations.as_deref()) {
|
||||
return match mode {
|
||||
DecorationOverride::Native => true,
|
||||
DecorationOverride::None => false,
|
||||
DecorationOverride::Auto => default_use_decorations(env),
|
||||
};
|
||||
}
|
||||
|
||||
if is_truthy(env.oc_force_decorations.as_deref()) {
|
||||
return true;
|
||||
}
|
||||
if is_truthy(env.oc_no_decorations.as_deref()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
default_use_decorations(env)
|
||||
}
|
||||
|
||||
fn default_use_decorations(env: &SessionEnv) -> bool {
|
||||
if is_known_tiling_session(env) {
|
||||
return false;
|
||||
}
|
||||
if !is_wayland_session(env) {
|
||||
return true;
|
||||
}
|
||||
is_full_desktop_session(env)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum DecorationOverride {
|
||||
Auto,
|
||||
Native,
|
||||
None,
|
||||
}
|
||||
|
||||
fn decoration_override(value: Option<&str>) -> Option<DecorationOverride> {
|
||||
let value = value?.trim().to_ascii_lowercase();
|
||||
if matches!(value.as_str(), "auto") {
|
||||
return Some(DecorationOverride::Auto);
|
||||
}
|
||||
if matches!(
|
||||
value.as_str(),
|
||||
"native" | "server" | "de" | "wayland" | "on" | "true" | "1"
|
||||
) {
|
||||
return Some(DecorationOverride::Native);
|
||||
}
|
||||
if matches!(
|
||||
value.as_str(),
|
||||
"none" | "off" | "false" | "0" | "client" | "csd"
|
||||
) {
|
||||
return Some(DecorationOverride::None);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn is_truthy(value: Option<&str>) -> bool {
|
||||
matches!(
|
||||
value.map(|v| v.trim().to_ascii_lowercase()),
|
||||
Some(v) if matches!(v.as_str(), "1" | "true" | "yes" | "on")
|
||||
)
|
||||
}
|
||||
|
||||
fn is_wayland_session(env: &SessionEnv) -> bool {
|
||||
env.wayland_display
|
||||
|| matches!(
|
||||
env.xdg_session_type.as_deref(),
|
||||
Some(value) if value.eq_ignore_ascii_case("wayland")
|
||||
)
|
||||
}
|
||||
|
||||
fn is_full_desktop_session(env: &SessionEnv) -> bool {
|
||||
desktop_tokens(env).any(|value| {
|
||||
matches!(
|
||||
value.as_str(),
|
||||
"gnome"
|
||||
| "kde"
|
||||
| "plasma"
|
||||
| "xfce"
|
||||
| "xfce4"
|
||||
| "x-cinnamon"
|
||||
| "cinnamon"
|
||||
| "mate"
|
||||
| "lxqt"
|
||||
| "budgie"
|
||||
| "pantheon"
|
||||
| "deepin"
|
||||
| "unity"
|
||||
| "cosmic"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_known_tiling_session(env: &SessionEnv) -> bool {
|
||||
if env.i3_sock {
|
||||
return true;
|
||||
}
|
||||
|
||||
desktop_tokens(env).any(|value| {
|
||||
matches!(
|
||||
value.as_str(),
|
||||
"niri"
|
||||
| "sway"
|
||||
| "swayfx"
|
||||
| "hyprland"
|
||||
| "river"
|
||||
| "i3"
|
||||
| "i3wm"
|
||||
| "bspwm"
|
||||
| "dwm"
|
||||
| "qtile"
|
||||
| "xmonad"
|
||||
| "leftwm"
|
||||
| "dwl"
|
||||
| "awesome"
|
||||
| "herbstluftwm"
|
||||
| "spectrwm"
|
||||
| "worm"
|
||||
| "i3-gnome"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn desktop_tokens<'a>(env: &'a SessionEnv) -> impl Iterator<Item = String> + 'a {
|
||||
[
|
||||
env.xdg_current_desktop.as_deref(),
|
||||
env.xdg_session_desktop.as_deref(),
|
||||
env.desktop_session.as_deref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flat_map(|desktop| desktop.split(':'))
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefers_wayland_first_on_wayland_session() {
|
||||
let env = SessionEnv {
|
||||
wayland_display: true,
|
||||
display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, false).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_x11_override_wins() {
|
||||
let env = SessionEnv {
|
||||
wayland_display: true,
|
||||
display: true,
|
||||
oc_force_x11: Some("1".into()),
|
||||
oc_allow_wayland: Some("1".into()),
|
||||
oc_force_wayland: Some("1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, true).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::X11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefer_wayland_forces_wayland_backend() {
|
||||
let env = SessionEnv {
|
||||
wayland_display: true,
|
||||
display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, true).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::Wayland);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_wayland_override_works_outside_wayland_session() {
|
||||
let env = SessionEnv {
|
||||
display: true,
|
||||
oc_force_wayland: Some("1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, false).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::Wayland);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_wayland_forces_wayland_backend() {
|
||||
let env = SessionEnv {
|
||||
wayland_display: true,
|
||||
display: true,
|
||||
oc_allow_wayland: Some("1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, false).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::Wayland);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_session_type_wayland_is_detected() {
|
||||
let env = SessionEnv {
|
||||
xdg_session_type: Some("wayland".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let decision = select_backend(&env, false).expect("missing decision");
|
||||
assert_eq!(decision.backend, Backend::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_not_wayland_and_no_overrides() {
|
||||
let env = SessionEnv {
|
||||
display: true,
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(select_backend(&env, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefer_wayland_setting_does_not_override_x11_session() {
|
||||
let env = SessionEnv {
|
||||
display: true,
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(select_backend(&env, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_on_niri() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("niri".into()),
|
||||
wayland_display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_decorations_on_gnome() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
wayland_display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_when_session_desktop_is_tiling() {
|
||||
let env = SessionEnv {
|
||||
xdg_session_desktop: Some("Hyprland".into()),
|
||||
wayland_display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_for_unknown_wayland_session() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("labwc".into()),
|
||||
wayland_display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_for_dwm_on_x11() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("dwm".into()),
|
||||
display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_for_i3_on_x11() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("i3".into()),
|
||||
display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disables_decorations_for_i3sock_without_xdg_tokens() {
|
||||
let env = SessionEnv {
|
||||
display: true,
|
||||
i3_sock: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_decorations_for_gnome_on_x11() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
display: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_decorations_override_wins() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
oc_no_decorations: Some("1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_decorations_native_override_wins() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("niri".into()),
|
||||
wayland_display: true,
|
||||
oc_linux_decorations: Some("native".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_decorations_none_override_wins() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
wayland_display: true,
|
||||
oc_linux_decorations: Some("none".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_decorations_auto_uses_default_policy() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("sway".into()),
|
||||
wayland_display: true,
|
||||
oc_linux_decorations: Some("auto".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_decorations_override_beats_legacy_overrides() {
|
||||
let env = SessionEnv {
|
||||
xdg_current_desktop: Some("GNOME".into()),
|
||||
wayland_display: true,
|
||||
oc_linux_decorations: Some("none".into()),
|
||||
oc_force_decorations: Some("1".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert!(!use_decorations(&env));
|
||||
}
|
||||
}
|
||||
76
qimingcode/packages/desktop/src-tauri/src/logging.rs
Normal file
76
qimingcode/packages/desktop/src-tauri/src/logging.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
78
qimingcode/packages/desktop/src-tauri/src/main.rs
Normal file
78
qimingcode/packages/desktop/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
// 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 opencode_lib::linux_windowing::{Backend, SessionEnv, select_backend};
|
||||
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 session = SessionEnv::capture();
|
||||
let prefer_wayland = opencode_lib::linux_display::read_wayland().unwrap_or(false);
|
||||
let decision = select_backend(&session, prefer_wayland)?;
|
||||
|
||||
match decision.backend {
|
||||
Backend::X11 => {
|
||||
set_env_if_absent("WINIT_UNIX_BACKEND", "x11");
|
||||
set_env_if_absent("GDK_BACKEND", "x11");
|
||||
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
Backend::Wayland => {
|
||||
set_env_if_absent("WINIT_UNIX_BACKEND", "wayland");
|
||||
set_env_if_absent("GDK_BACKEND", "wayland");
|
||||
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
Backend::Auto => {
|
||||
set_env_if_absent("GDK_BACKEND", "wayland,x11");
|
||||
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
}
|
||||
|
||||
Some(decision.note)
|
||||
}
|
||||
|
||||
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
qimingcode/packages/desktop/src-tauri/src/markdown.rs
Normal file
63
qimingcode/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))
|
||||
}
|
||||
2
qimingcode/packages/desktop/src-tauri/src/os/mod.rs
Normal file
2
qimingcode/packages/desktop/src-tauri/src/os/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
463
qimingcode/packages/desktop/src-tauri/src/os/windows.rs
Normal file
463
qimingcode/packages/desktop/src-tauri/src/os/windows.rs
Normal file
@@ -0,0 +1,463 @@
|
||||
use std::{
|
||||
ffi::c_void,
|
||||
os::windows::process::CommandExt,
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::ERROR_SUCCESS,
|
||||
System::{
|
||||
Registry::{
|
||||
RegGetValueW, HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, REG_EXPAND_SZ, REG_SZ,
|
||||
RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ,
|
||||
},
|
||||
Threading::{CREATE_NEW_CONSOLE, CREATE_NO_WINDOW},
|
||||
},
|
||||
};
|
||||
|
||||
pub fn check_windows_app(app_name: &str) -> bool {
|
||||
resolve_windows_app_path(app_name).is_some()
|
||||
}
|
||||
|
||||
pub fn resolve_windows_app_path(app_name: &str) -> Option<String> {
|
||||
fn expand_env(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut index = 0;
|
||||
|
||||
while let Some(start) = value[index..].find('%') {
|
||||
let start = index + start;
|
||||
out.push_str(&value[index..start]);
|
||||
|
||||
let Some(end_rel) = value[start + 1..].find('%') else {
|
||||
out.push_str(&value[start..]);
|
||||
return out;
|
||||
};
|
||||
|
||||
let end = start + 1 + end_rel;
|
||||
let key = &value[start + 1..end];
|
||||
if key.is_empty() {
|
||||
out.push('%');
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(v) = std::env::var(key) {
|
||||
out.push_str(&v);
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push_str(&value[start..=end]);
|
||||
index = end + 1;
|
||||
}
|
||||
|
||||
out.push_str(&value[index..]);
|
||||
out
|
||||
}
|
||||
|
||||
fn extract_exe(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(rest) = value.strip_prefix('"') {
|
||||
if let Some(end) = rest.find('"') {
|
||||
let inner = rest[..end].trim();
|
||||
if inner.to_ascii_lowercase().contains(".exe") {
|
||||
return Some(inner.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lower = value.to_ascii_lowercase();
|
||||
let end = lower.find(".exe")?;
|
||||
Some(value[..end + 4].trim().trim_matches('"').to_string())
|
||||
}
|
||||
|
||||
fn candidates(app_name: &str) -> Vec<String> {
|
||||
let app_name = app_name.trim().trim_matches('"');
|
||||
if app_name.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let mut out = Vec::<String>::new();
|
||||
let mut push = |value: String| {
|
||||
let value = value.trim().trim_matches('"').to_string();
|
||||
if value.is_empty() {
|
||||
return;
|
||||
}
|
||||
if out.iter().any(|v| v.eq_ignore_ascii_case(&value)) {
|
||||
return;
|
||||
}
|
||||
out.push(value);
|
||||
};
|
||||
|
||||
push(app_name.to_string());
|
||||
|
||||
let lower = app_name.to_ascii_lowercase();
|
||||
if !lower.ends_with(".exe") {
|
||||
push(format!("{app_name}.exe"));
|
||||
}
|
||||
|
||||
let snake = {
|
||||
let mut s = String::new();
|
||||
let mut underscore = false;
|
||||
for c in lower.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
s.push(c);
|
||||
underscore = false;
|
||||
continue;
|
||||
}
|
||||
if underscore {
|
||||
continue;
|
||||
}
|
||||
s.push('_');
|
||||
underscore = true;
|
||||
}
|
||||
s.trim_matches('_').to_string()
|
||||
};
|
||||
|
||||
if !snake.is_empty() {
|
||||
push(snake.clone());
|
||||
if !snake.ends_with(".exe") {
|
||||
push(format!("{snake}.exe"));
|
||||
}
|
||||
}
|
||||
|
||||
let alnum = lower
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.collect::<String>();
|
||||
|
||||
if !alnum.is_empty() {
|
||||
push(alnum.clone());
|
||||
push(format!("{alnum}.exe"));
|
||||
}
|
||||
|
||||
match lower.as_str() {
|
||||
"sublime text" | "sublime-text" | "sublime_text" | "sublime text.exe" => {
|
||||
push("subl".to_string());
|
||||
push("subl.exe".to_string());
|
||||
push("sublime_text".to_string());
|
||||
push("sublime_text.exe".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn reg_app_path(exe: &str) -> Option<String> {
|
||||
let exe = exe.trim().trim_matches('"');
|
||||
if exe.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let query = |root: *mut c_void, subkey: &str| -> Option<String> {
|
||||
let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ;
|
||||
let mut kind: u32 = 0;
|
||||
let mut size = 0u32;
|
||||
|
||||
let mut key = subkey.encode_utf16().collect::<Vec<_>>();
|
||||
key.push(0);
|
||||
|
||||
let status = unsafe {
|
||||
RegGetValueW(
|
||||
root,
|
||||
key.as_ptr(),
|
||||
std::ptr::null(),
|
||||
flags,
|
||||
&mut kind,
|
||||
std::ptr::null_mut(),
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
|
||||
if status != ERROR_SUCCESS || size == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
if kind != REG_SZ && kind != REG_EXPAND_SZ {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut data = vec![0u8; size as usize];
|
||||
let status = unsafe {
|
||||
RegGetValueW(
|
||||
root,
|
||||
key.as_ptr(),
|
||||
std::ptr::null(),
|
||||
flags,
|
||||
&mut kind,
|
||||
data.as_mut_ptr() as *mut c_void,
|
||||
&mut size,
|
||||
)
|
||||
};
|
||||
|
||||
if status != ERROR_SUCCESS || size < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let words = unsafe {
|
||||
std::slice::from_raw_parts(data.as_ptr().cast::<u16>(), (size as usize) / 2)
|
||||
};
|
||||
let len = words.iter().position(|v| *v == 0).unwrap_or(words.len());
|
||||
let value = String::from_utf16_lossy(&words[..len]).trim().to_string();
|
||||
|
||||
if value.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(value)
|
||||
};
|
||||
|
||||
let keys = [
|
||||
(
|
||||
HKEY_CURRENT_USER,
|
||||
format!(r"Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"),
|
||||
),
|
||||
(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
format!(r"Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"),
|
||||
),
|
||||
(
|
||||
HKEY_LOCAL_MACHINE,
|
||||
format!(r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\{exe}"),
|
||||
),
|
||||
];
|
||||
|
||||
for (root, key) in keys {
|
||||
let Some(value) = query(root, &key) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(exe) = extract_exe(&value) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let exe = expand_env(&exe);
|
||||
let path = Path::new(exe.trim().trim_matches('"'));
|
||||
if path.exists() {
|
||||
return Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
let app_name = app_name.trim().trim_matches('"');
|
||||
if app_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let direct = Path::new(app_name);
|
||||
if direct.is_absolute() && direct.exists() {
|
||||
return Some(direct.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
let key = app_name
|
||||
.chars()
|
||||
.filter(|v| v.is_ascii_alphanumeric())
|
||||
.flat_map(|v| v.to_lowercase())
|
||||
.collect::<String>();
|
||||
|
||||
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)
|
||||
};
|
||||
|
||||
let resolve_cmd = |path: &Path| -> Option<String> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
let content = String::from_utf8_lossy(&bytes);
|
||||
|
||||
for token in content.split('"') {
|
||||
let Some(exe) = extract_exe(token) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let lower = exe.to_ascii_lowercase();
|
||||
if let Some(index) = lower.find("%~dp0") {
|
||||
let base = path.parent()?;
|
||||
let suffix = &exe[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());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved = PathBuf::from(expand_env(&exe));
|
||||
if resolved.exists() {
|
||||
return Some(resolved.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
};
|
||||
|
||||
let resolve_where = |query: &str| -> Option<String> {
|
||||
let output = Command::new("where")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.arg(query)
|
||||
.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<_>>();
|
||||
|
||||
if paths.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
|
||||
return Some(path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
};
|
||||
|
||||
let list = candidates(app_name);
|
||||
for query in &list {
|
||||
if let Some(path) = resolve_where(query) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
let mut exes = Vec::<String>::new();
|
||||
for query in &list {
|
||||
let query = query.trim().trim_matches('"');
|
||||
if query.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = Path::new(query)
|
||||
.file_name()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or(query);
|
||||
|
||||
let exe = if name.to_ascii_lowercase().ends_with(".exe") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{name}.exe")
|
||||
};
|
||||
|
||||
if exes.iter().any(|v| v.eq_ignore_ascii_case(&exe)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
exes.push(exe);
|
||||
}
|
||||
|
||||
for exe in exes {
|
||||
if let Some(path) = reg_app_path(&exe) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn open_in_powershell(path: String) -> Result<(), String> {
|
||||
let path = PathBuf::from(path);
|
||||
let dir = if path.is_dir() {
|
||||
path
|
||||
} else if let Some(parent) = path.parent() {
|
||||
parent.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|e| format!("Failed to determine current directory: {e}"))?
|
||||
};
|
||||
|
||||
Command::new("powershell.exe")
|
||||
.creation_flags(CREATE_NEW_CONSOLE)
|
||||
.current_dir(dir)
|
||||
.args(["-NoExit"])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start PowerShell: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
170
qimingcode/packages/desktop/src-tauri/src/server.rs
Normal file
170
qimingcode/packages/desktop/src-tauri/src/server.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::{
|
||||
cli,
|
||||
cli::CommandChild,
|
||||
constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY},
|
||||
};
|
||||
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug, Default)]
|
||||
pub struct WslConfig {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[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(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn get_wsl_config(_app: AppHandle) -> Result<WslConfig, String> {
|
||||
// let store = app
|
||||
// .store(SETTINGS_STORE)
|
||||
// .map_err(|e| format!("Failed to open settings store: {}", e))?;
|
||||
|
||||
// let enabled = store
|
||||
// .get(WSL_ENABLED_KEY)
|
||||
// .as_ref()
|
||||
// .and_then(|v| v.as_bool())
|
||||
// .unwrap_or(false);
|
||||
|
||||
Ok(WslConfig { enabled: false })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> {
|
||||
let store = app
|
||||
.store(SETTINGS_STORE)
|
||||
.map_err(|e| format!("Failed to open settings store: {}", e))?;
|
||||
|
||||
store.set(WSL_ENABLED_KEY, serde_json::Value::Bool(config.enabled));
|
||||
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn spawn_local_server(
|
||||
app: AppHandle,
|
||||
hostname: String,
|
||||
port: u32,
|
||||
password: String,
|
||||
) -> (CommandChild, HealthCheck) {
|
||||
let (child, exit) = cli::serve(&app, &hostname, port, &password);
|
||||
|
||||
let health_check = HealthCheck(tokio::spawn(async move {
|
||||
let url = format!("http://{hostname}:{port}");
|
||||
let timestamp = Instant::now();
|
||||
|
||||
let ready = async {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
if check_health(&url, Some(&password)).await {
|
||||
tracing::info!(elapsed = ?timestamp.elapsed(), "Server ready");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let terminated = async {
|
||||
match exit.await {
|
||||
Ok(payload) => Err(format!(
|
||||
"Sidecar terminated before becoming healthy (code={:?} signal={:?})",
|
||||
payload.code, payload.signal
|
||||
)),
|
||||
Err(_) => Err("Sidecar terminated before becoming healthy".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
res = ready => res,
|
||||
res = terminated => res,
|
||||
}
|
||||
}));
|
||||
|
||||
(child, health_check)
|
||||
}
|
||||
|
||||
pub struct HealthCheck(pub JoinHandle<Result<(), String>>);
|
||||
|
||||
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(7));
|
||||
|
||||
if 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())
|
||||
})
|
||||
{
|
||||
// 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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
174
qimingcode/packages/desktop/src-tauri/src/windows.rs
Normal file
174
qimingcode/packages/desktop/src-tauri/src/windows.rs
Normal file
@@ -0,0 +1,174 @@
|
||||
use crate::{
|
||||
constants::{UPDATER_ENABLED, window_state_flags},
|
||||
server::get_wsl_config,
|
||||
};
|
||||
use std::{ops::Deref, time::Duration};
|
||||
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||
use tauri_plugin_window_state::AppHandleExt;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn use_decorations() -> bool {
|
||||
static DECORATIONS: OnceLock<bool> = OnceLock::new();
|
||||
*DECORATIONS.get_or_init(|| {
|
||||
crate::linux_windowing::use_decorations(&crate::linux_windowing::SessionEnv::capture())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn use_decorations() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
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) {
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
return Ok(Self(window));
|
||||
}
|
||||
|
||||
let wsl_enabled = get_wsl_config(app.clone())
|
||||
.ok()
|
||||
.map(|v| v.enabled)
|
||||
.unwrap_or(false);
|
||||
let decorations = use_decorations();
|
||||
let window_builder = base_window_config(
|
||||
WebviewWindowBuilder::new(app, Self::LABEL, WebviewUrl::App("/".into())),
|
||||
app,
|
||||
decorations,
|
||||
)
|
||||
.title("OpenCode")
|
||||
.disable_drag_drop_handler()
|
||||
.zoom_hotkeys_enabled(false)
|
||||
.visible(true)
|
||||
.maximized(true)
|
||||
.initialization_script(format!(
|
||||
r#"
|
||||
window.__OPENCODE__ ??= {{}};
|
||||
window.__OPENCODE__.updaterEnabled = {UPDATER_ENABLED};
|
||||
window.__OPENCODE__.wsl = {wsl_enabled};
|
||||
"#
|
||||
));
|
||||
|
||||
let window = window_builder.build()?;
|
||||
|
||||
// Ensure window is focused after creation (e.g., after update/relaunch)
|
||||
let _ = window.set_focus();
|
||||
|
||||
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 decorations = use_decorations();
|
||||
|
||||
let window_builder = base_window_config(
|
||||
WebviewWindowBuilder::new(app, Self::LABEL, tauri::WebviewUrl::App("/loading".into())),
|
||||
app,
|
||||
decorations,
|
||||
)
|
||||
.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,
|
||||
decorations: bool,
|
||||
) -> WebviewWindowBuilder<'a, R, M> {
|
||||
let window_builder = window_builder.decorations(decorations);
|
||||
|
||||
#[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