chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,908 @@
|
||||
use std::process::{Command, Child, Stdio};
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::sync::Mutex;
|
||||
use std::net::TcpStream;
|
||||
use std::time::{Duration, Instant};
|
||||
use std::thread;
|
||||
use std::path::PathBuf;
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
static BRIDGE_PROCESS: Mutex<Option<Child>> = Mutex::new(None);
|
||||
|
||||
/// Get project root (parent of frontends/)
|
||||
fn project_root() -> PathBuf {
|
||||
std::env::current_exe()
|
||||
.expect("cannot get exe path")
|
||||
.parent().expect("cannot get exe dir") // frontends/
|
||||
.parent().expect("cannot get project root") // project root
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
/// Directory next to which a self-contained bundle keeps its runtime/ folder.
|
||||
/// Windows: the exe's folder. Linux: the .AppImage's folder ($APPIMAGE) when launched as an
|
||||
/// AppImage (current_exe would otherwise point inside the read-only squashfs mount).
|
||||
/// macOS portable package: the folder containing GenericAgent.app and runtime/.
|
||||
fn bundle_anchor_dir() -> Option<PathBuf> {
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
if let Some(p) = std::env::var_os("APPIMAGE") {
|
||||
if let Some(d) = PathBuf::from(p).parent() {
|
||||
return Some(d.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// current_exe() inside a bundle is:
|
||||
// <package>/GenericAgent.app/Contents/MacOS/GenericAgent
|
||||
// Prefer the standard macOS layout where runtime is embedded in the app:
|
||||
// GenericAgent.app/Contents/Resources/runtime/app/agentmain.py
|
||||
// Fall back to the old portable layout for compatibility:
|
||||
// <package>/runtime/app/agentmain.py
|
||||
let mut d = exe.parent();
|
||||
while let Some(dir) = d {
|
||||
if dir.extension().and_then(|s| s.to_str()) == Some("app") {
|
||||
let resources = dir.join("Contents").join("Resources");
|
||||
if resources.join("runtime").join("app").join("agentmain.py").exists() {
|
||||
return Some(resources);
|
||||
}
|
||||
if let Some(parent) = dir.parent() {
|
||||
return Some(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
d = dir.parent();
|
||||
}
|
||||
}
|
||||
|
||||
Some(exe.parent()?.to_path_buf())
|
||||
}
|
||||
|
||||
/// Embedded interpreter inside the bundle's runtime/python (base python, before venv).
|
||||
fn bundle_python() -> Option<PathBuf> {
|
||||
let root = bundle_root()?;
|
||||
#[cfg(windows)]
|
||||
let p = root.join("python").join("python.exe");
|
||||
#[cfg(not(windows))]
|
||||
let p = root.join("python").join("bin").join("python3");
|
||||
if p.exists() { Some(p) } else { None }
|
||||
}
|
||||
|
||||
/// Find python executable:
|
||||
/// 1. The embedded bundle python (runtime/python) — deps are installed directly into it
|
||||
/// (no venv), and its path is resolved relative to the bundle anchor at runtime, so the
|
||||
/// package stays relocatable (moving the folder doesn't break absolute venv paths).
|
||||
/// 2. .portable/uv-python/ 下找 python.exe (Windows) 或 python3 (Unix)
|
||||
/// 3. Fallback to system PATH
|
||||
fn find_python() -> String {
|
||||
if let Some(p) = bundle_python() {
|
||||
return p.to_string_lossy().to_string();
|
||||
}
|
||||
let root = project_root();
|
||||
let portable_python_dir = root.join(".portable").join("uv-python");
|
||||
|
||||
if portable_python_dir.exists() {
|
||||
// uv installs python like: uv-python/cpython-3.12.x-windows-x86_64/python.exe
|
||||
// We need to search for python.exe inside subdirectories
|
||||
if let Ok(entries) = std::fs::read_dir(&portable_python_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let py = path.join("python.exe");
|
||||
if py.exists() {
|
||||
return py.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
let py = path.join("bin").join("python3");
|
||||
if py.exists() {
|
||||
return py.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: system PATH
|
||||
#[cfg(windows)]
|
||||
{ "python".to_string() }
|
||||
#[cfg(not(windows))]
|
||||
{ "python3".to_string() }
|
||||
}
|
||||
|
||||
/// Find the project directory (folder containing agentmain.py).
|
||||
/// Bundle layout: <exe dir>/runtime/app/agentmain.py. Dev layout: walk up from the exe.
|
||||
fn find_project_dir() -> Option<String> {
|
||||
// Bundle layout: source tucked under <anchor>/runtime/app/
|
||||
if let Some(anchor) = bundle_anchor_dir() {
|
||||
let app = anchor.join("runtime").join("app");
|
||||
if app.join("agentmain.py").exists() {
|
||||
return Some(app.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Dev/source layout: walk up to 8 levels from the exe location.
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let mut dir = Some(exe.parent()?);
|
||||
for _ in 0..8 {
|
||||
match dir {
|
||||
Some(d) => {
|
||||
if d.join("agentmain.py").exists() {
|
||||
return Some(d.to_string_lossy().to_string());
|
||||
}
|
||||
dir = d.parent();
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Settings file path: ~/.ga_desktop_settings.json
|
||||
fn settings_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(".ga_desktop_settings.json")
|
||||
}
|
||||
|
||||
/// Read the settings file as a JSON object (empty object when missing/unparseable).
|
||||
fn read_settings() -> serde_json::Map<String, serde_json::Value> {
|
||||
let path = settings_path();
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(serde_json::Value::Object(m)) = serde_json::from_str(&content) {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
serde_json::Map::new()
|
||||
}
|
||||
|
||||
/// Merge `updates` into the existing settings file and write it back, preserving any keys
|
||||
/// we don't touch. The old code rewrote the file with only python_path/project_dir, which
|
||||
/// would silently drop sibling keys like `desktop_shortcut`. Always go through here.
|
||||
fn merge_settings(updates: serde_json::Value) {
|
||||
let mut obj = read_settings();
|
||||
if let serde_json::Value::Object(m) = updates {
|
||||
for (k, v) in m {
|
||||
obj.insert(k, v);
|
||||
}
|
||||
}
|
||||
let val = serde_json::Value::Object(obj);
|
||||
if let Ok(text) = serde_json::to_string_pretty(&val) {
|
||||
let _ = std::fs::write(settings_path(), text);
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop-shortcut preference stored in settings under `desktop_shortcut`.
|
||||
/// None = never asked (first run)
|
||||
/// Some(true)/Some(false) = user's remembered choice.
|
||||
fn read_shortcut_pref() -> Option<bool> {
|
||||
read_settings().get("desktop_shortcut").and_then(|v| v.as_bool())
|
||||
}
|
||||
|
||||
fn write_shortcut_pref(enabled: bool) {
|
||||
merge_settings(serde_json::json!({ "desktop_shortcut": enabled }));
|
||||
}
|
||||
|
||||
/// Create (or overwrite) a desktop shortcut pointing at the CURRENT exe. Overwriting on every
|
||||
/// enabled launch is what makes the portable bundle relocatable: move the folder, relaunch, and
|
||||
/// the shortcut is rewritten to the new path. Windows-only (uses a .lnk via WScript.Shell).
|
||||
#[cfg(windows)]
|
||||
fn ensure_desktop_shortcut() {
|
||||
let Ok(exe) = std::env::current_exe() else { return; };
|
||||
let Some(desktop) = dirs::desktop_dir() else { return; };
|
||||
let lnk = desktop.join("GenericAgent.lnk");
|
||||
let work_dir = exe.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| exe.clone());
|
||||
|
||||
let exe_s = exe.to_string_lossy().replace('\'', "''");
|
||||
let lnk_s = lnk.to_string_lossy().replace('\'', "''");
|
||||
let work_s = work_dir.to_string_lossy().replace('\'', "''");
|
||||
|
||||
// Build the shortcut via WScript.Shell COM, consistent with the existing powershell usage
|
||||
// elsewhere in this file. No extra crate needed.
|
||||
let script = format!(
|
||||
"$ws = New-Object -ComObject WScript.Shell; \
|
||||
$sc = $ws.CreateShortcut('{lnk}'); \
|
||||
$sc.TargetPath = '{exe}'; \
|
||||
$sc.WorkingDirectory = '{work}'; \
|
||||
$sc.IconLocation = '{exe}'; \
|
||||
$sc.Save()",
|
||||
lnk = lnk_s, exe = exe_s, work = work_s
|
||||
);
|
||||
|
||||
let mut cmd = Command::new("powershell.exe");
|
||||
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]);
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
let _ = cmd.status();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ensure_desktop_shortcut() {
|
||||
// Launch target: the AppImage path when running as one, else the current exe. Writing the
|
||||
// current path on every enabled launch keeps a relocated bundle's launcher valid.
|
||||
let Some(target) = std::env::var_os("APPIMAGE").map(PathBuf::from)
|
||||
.or_else(|| std::env::current_exe().ok()) else { return; };
|
||||
let exec = target.to_string_lossy().replace('"', "");
|
||||
// Linux .desktop Icon= needs an image file (or themed name), not the AppImage path. The CI
|
||||
// ships GenericAgent.png next to the AppImage; fall back to a generic themed icon otherwise.
|
||||
let icon = bundle_anchor_dir()
|
||||
.map(|d| d.join("GenericAgent.png"))
|
||||
.filter(|p| p.exists())
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "application-x-executable".to_string());
|
||||
let entry = format!(
|
||||
"[Desktop Entry]\nType=Application\nName=GenericAgent\nComment=GenericAgent Desktop\n\
|
||||
Exec=\"{exec}\"\nIcon={icon}\nTerminal=false\nCategories=Utility;Development;\n",
|
||||
exec = exec, icon = icon
|
||||
);
|
||||
let write_desktop = |path: &std::path::Path| {
|
||||
if std::fs::write(path, &entry).is_ok() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
|
||||
}
|
||||
};
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
let apps = home.join(".local/share/applications");
|
||||
let _ = std::fs::create_dir_all(&apps);
|
||||
write_desktop(&apps.join("GenericAgent.desktop"));
|
||||
}
|
||||
if let Some(desktop) = dirs::desktop_dir() {
|
||||
let _ = std::fs::create_dir_all(&desktop);
|
||||
let f = desktop.join("GenericAgent.desktop");
|
||||
write_desktop(&f);
|
||||
// GNOME marks unknown launchers "untrusted"; flag ours so it runs on double-click. Best effort.
|
||||
let _ = Command::new("gio")
|
||||
.args(["set", &f.to_string_lossy(), "metadata::trusted", "true"])
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn ensure_desktop_shortcut() {
|
||||
// The .app is the launchable unit; drop a symlink to it on the Desktop.
|
||||
let Ok(exe) = std::env::current_exe() else { return; };
|
||||
let mut app: Option<PathBuf> = None;
|
||||
let mut d = exe.parent();
|
||||
while let Some(dir) = d {
|
||||
if dir.extension().and_then(|s| s.to_str()) == Some("app") { app = Some(dir.to_path_buf()); break; }
|
||||
d = dir.parent();
|
||||
}
|
||||
let (Some(app), Some(desktop)) = (app, dirs::desktop_dir()) else { return; };
|
||||
let link = desktop.join("GenericAgent.app");
|
||||
let _ = std::fs::remove_file(&link);
|
||||
let _ = std::os::unix::fs::symlink(&app, &link);
|
||||
}
|
||||
|
||||
#[cfg(all(not(windows), not(target_os = "linux"), not(target_os = "macos")))]
|
||||
fn ensure_desktop_shortcut() {}
|
||||
|
||||
/// First-run shortcut handling for portable bundles (all platforms). Self-heals the shortcut
|
||||
/// path on every enabled launch (cheap, no UI). The first-run ASK is driven by the frontend
|
||||
/// (see the `shortcut_should_ask` / `shortcut_decide` commands): a native dialog from this
|
||||
/// background startup thread has no parent window and gets buried behind the main window on
|
||||
/// first launch, so the prompt is owned by the web UI instead, which always renders on top.
|
||||
fn maybe_setup_shortcut() {
|
||||
if bundle_root().is_none() {
|
||||
return;
|
||||
}
|
||||
// Only self-heal when the user already opted in. Never prompt here.
|
||||
if read_shortcut_pref() == Some(true) {
|
||||
ensure_desktop_shortcut();
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend asks whether to show the first-run "create desktop shortcut?" prompt.
|
||||
/// True only on a portable bundle whose preference has never been set.
|
||||
#[tauri::command]
|
||||
fn shortcut_should_ask() -> bool {
|
||||
bundle_root().is_some() && read_shortcut_pref().is_none()
|
||||
}
|
||||
|
||||
/// Frontend reports the user's choice. Persists it and creates the shortcut when enabled.
|
||||
#[tauri::command]
|
||||
fn shortcut_decide(create: bool) {
|
||||
write_shortcut_pref(create);
|
||||
if create {
|
||||
ensure_desktop_shortcut();
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this binary is running from inside a macOS .app bundle (packaged build).
|
||||
/// Used to refuse stale ~/.ga_desktop_settings.json that could point at an old checkout
|
||||
/// when App Translocation hides our own runtime/ from current_exe().
|
||||
#[cfg(target_os = "macos")]
|
||||
fn running_inside_app_bundle() -> bool {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| {
|
||||
p.components().any(|c| {
|
||||
c.as_os_str().to_string_lossy().ends_with(".app")
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Read config from settings file, or auto-discover and save.
|
||||
/// Self-contained bundles always prefer their own runtime/app over stale user settings,
|
||||
/// otherwise an old ~/.ga_desktop_settings.json can silently point the UI at a different checkout.
|
||||
pub fn get_or_discover_config() -> (String, String) {
|
||||
let path = settings_path();
|
||||
|
||||
if bundle_root().is_some() {
|
||||
let python = find_python();
|
||||
let project = find_project_dir().unwrap_or_default();
|
||||
if !python.is_empty() && !project.is_empty() {
|
||||
merge_settings(serde_json::json!({
|
||||
"python_path": python,
|
||||
"project_dir": project
|
||||
}));
|
||||
return (python, project);
|
||||
}
|
||||
}
|
||||
|
||||
// Try reading existing settings.
|
||||
// On macOS, a packaged .app must never trust ~/.ga_desktop_settings.json: App
|
||||
// Translocation can run the bundle from a random read-only copy where bundle_root()
|
||||
// fails to see our own runtime/, and an old settings file would then silently point
|
||||
// the bridge at a previously installed checkout. In that case fall through to
|
||||
// auto-discovery (which still resolves the bundle via .app-relative search below).
|
||||
#[cfg(target_os = "macos")]
|
||||
let trust_settings = !running_inside_app_bundle();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let trust_settings = true;
|
||||
|
||||
if trust_settings && path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let python = val.get("python_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let project = val.get("project_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if !python.is_empty() && !project.is_empty() {
|
||||
return (python, project);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-discover
|
||||
let python = find_python();
|
||||
let project = find_project_dir().unwrap_or_default();
|
||||
|
||||
// Save discovered config
|
||||
if !python.is_empty() && !project.is_empty() {
|
||||
merge_settings(serde_json::json!({
|
||||
"python_path": python,
|
||||
"project_dir": project
|
||||
}));
|
||||
}
|
||||
|
||||
(python, project)
|
||||
}
|
||||
|
||||
/// Self-contained bundle support dir: holds python/, wheels/, install_windows.ps1 and app/.
|
||||
/// Typical portable layout keeps only the exe (+README) at the top level and tucks everything
|
||||
/// else under <exe dir>/runtime/. Returns None when this is not a bundle (e.g. dev build).
|
||||
fn bundle_root() -> Option<PathBuf> {
|
||||
let runtime = bundle_anchor_dir()?.join("runtime");
|
||||
if runtime.join("app").join("agentmain.py").exists() {
|
||||
return Some(runtime);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Marker written after a successful offline prepare. Lives under runtime/ so it travels
|
||||
/// with the bundle: a relocated folder stays "prepared" (deps live in the embedded python,
|
||||
/// which is itself relocatable) and won't re-run prepare.
|
||||
fn prepared_marker() -> Option<PathBuf> {
|
||||
Some(bundle_root()?.join(".prepared"))
|
||||
}
|
||||
|
||||
/// True when this is a self-contained bundle whose python env has not been prepared yet
|
||||
/// (embedded python present but deps not yet installed into it).
|
||||
fn needs_first_run_prepare(project_dir: &str) -> bool {
|
||||
if project_dir.is_empty() { return false; }
|
||||
bundle_python().is_some() && prepared_marker().map(|m| !m.exists()).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Clear env vars a host launcher injects pointing at its own runtime. The Linux AppImage exports
|
||||
/// PYTHONHOME/PYTHONPATH (-> bundled python crashes with "No module named 'encodings'") and
|
||||
/// LD_LIBRARY_PATH (-> wrong shared libs). Our bundled python / prepare / bridge must run clean.
|
||||
fn sanitize_bundle_env(cmd: &mut Command) {
|
||||
cmd.env_remove("PYTHONHOME");
|
||||
cmd.env_remove("PYTHONPATH");
|
||||
cmd.env_remove("LD_LIBRARY_PATH");
|
||||
// Stamp the bridge we spawn with this build's id so a later app launch can tell whether the
|
||||
// bridge holding :14168 is ours (see bridge_identity_matches / GET /services/identity).
|
||||
cmd.env("GA_BUILD_ID", env!("GA_BUILD_ID"));
|
||||
}
|
||||
|
||||
/// Run the offline prepare (install_windows.ps1 -Mode PrepareOnly) using bundled python + wheels.
|
||||
/// Streams the script's stdout and forwards GAPROGRESS markers to `report(pct, message)`.
|
||||
/// Blocking; intended to run on a background thread. Writes ~/.ga_desktop_settings.json.
|
||||
fn run_offline_prepare(project_dir: &str, report: &dyn Fn(i32, &str)) -> Result<(), String> {
|
||||
let root = bundle_root().ok_or("cannot locate bundle root")?;
|
||||
let wheels = root.join("wheels");
|
||||
|
||||
#[cfg(windows)]
|
||||
let (script, py) = (
|
||||
root.join("install_windows.ps1"),
|
||||
root.join("python").join("python.exe"),
|
||||
);
|
||||
#[cfg(target_os = "macos")]
|
||||
let (script, py) = (
|
||||
root.join("install_macos.sh"),
|
||||
root.join("python").join("bin").join("python3"),
|
||||
);
|
||||
#[cfg(all(not(windows), not(target_os = "macos")))]
|
||||
let (script, py) = (
|
||||
root.join("install_linux.sh"),
|
||||
root.join("python").join("bin").join("python3"),
|
||||
);
|
||||
|
||||
if !script.exists() || !py.exists() || !wheels.exists() {
|
||||
return Err(format!("prepare resources missing under {:?}", root));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("powershell.exe");
|
||||
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"])
|
||||
.arg(&script)
|
||||
.arg("-PythonPath").arg(&py)
|
||||
.arg("-ProjectDir").arg(project_dir)
|
||||
.arg("-WheelDir").arg(&wheels)
|
||||
.arg("-ExtraPipPackages").arg("fastapi uvicorn websockets")
|
||||
// -NoVenv: install deps straight into the embedded python (no venv) so the
|
||||
// bundle is relocatable. See prepared_marker / find_python.
|
||||
.args(["-Mode", "PrepareOnly", "-SkipNpmInstall", "-NoVenv"]);
|
||||
c
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let mut cmd = {
|
||||
let mut c = Command::new("bash");
|
||||
c.arg(&script)
|
||||
.arg("--python-path").arg(&py)
|
||||
.arg("--project-dir").arg(project_dir)
|
||||
.arg("--wheel-dir").arg(&wheels)
|
||||
.arg("--extra-packages").arg("fastapi uvicorn websockets")
|
||||
// --no-venv: install deps straight into the embedded python (no venv) so the
|
||||
// bundle is relocatable. See prepared_marker / find_python.
|
||||
.args(["--mode", "PrepareOnly", "--no-venv"]);
|
||||
c
|
||||
};
|
||||
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
|
||||
sanitize_bundle_env(&mut cmd);
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
let mut child = cmd.spawn().map_err(|e| format!("failed to launch prepare: {}", e))?;
|
||||
|
||||
// Forward the script's ASCII progress keys to the loading window, which localizes them
|
||||
// (window.gaProgress maps key -> zh/en by navigator.language).
|
||||
if let Some(out) = child.stdout.take() {
|
||||
for line in BufReader::new(out).lines().flatten() {
|
||||
if let Some(key) = line.trim().strip_prefix("GAPROGRESS|") {
|
||||
match key.trim() {
|
||||
"venv" => report(15, "venv"),
|
||||
"deps" => report(45, "deps"),
|
||||
"done" => report(90, "done"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child.wait().map_err(|e| format!("prepare wait failed: {}", e))?;
|
||||
if !status.success() {
|
||||
return Err(format!("prepare exited with status {:?}", status.code()));
|
||||
}
|
||||
// Record success so later launches (and relocated copies) skip the prepare step.
|
||||
if let Some(marker) = prepared_marker() {
|
||||
let _ = std::fs::write(&marker, b"ok\n");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// GET /services/identity from a running bridge; returns the parsed JSON (or None when the
|
||||
/// endpoint is absent — i.e. an older/foreign bridge).
|
||||
fn bridge_reported_identity() -> Option<serde_json::Value> {
|
||||
use std::io::{Read, Write};
|
||||
let mut stream = TcpStream::connect_timeout(
|
||||
&"127.0.0.1:14168".parse().unwrap(),
|
||||
Duration::from_millis(800),
|
||||
).ok()?;
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
|
||||
let req = b"GET /services/identity HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
|
||||
stream.write_all(req).ok()?;
|
||||
let mut buf = Vec::new();
|
||||
let _ = stream.read_to_end(&mut buf);
|
||||
let text = String::from_utf8_lossy(&buf);
|
||||
let body = text.split("\r\n\r\n").nth(1)?;
|
||||
serde_json::from_str(body.trim()).ok()
|
||||
}
|
||||
|
||||
fn norm_path(p: &str) -> String {
|
||||
std::fs::canonicalize(p)
|
||||
.map(|c| c.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| p.to_string())
|
||||
}
|
||||
|
||||
/// A running bridge is "ours" only when it serves the same install path AND was spawned by the
|
||||
/// same build. The build id (commit+timestamp, see build.rs) changes on every build, so an
|
||||
/// in-place upgrade or a same-version re-publish still counts as a different bridge → take over.
|
||||
/// An old bridge with no /identity (None) or no build_id field ("") never matches → taken over.
|
||||
fn bridge_identity_matches(project_dir: &str) -> bool {
|
||||
let Some(id) = bridge_reported_identity() else { return false; };
|
||||
let reported_root = id.get("ga_root").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let reported_build = id.get("build_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if reported_build != env!("GA_BUILD_ID") {
|
||||
return false;
|
||||
}
|
||||
let (a, b) = (norm_path(reported_root), norm_path(project_dir));
|
||||
#[cfg(windows)]
|
||||
{ a.eq_ignore_ascii_case(&b) }
|
||||
#[cfg(not(windows))]
|
||||
{ a == b }
|
||||
}
|
||||
|
||||
/// Last resort when a stale bridge ignores POST /services/bridge/exit (e.g. an old build with
|
||||
/// no such endpoint): force-kill whatever process is listening on :14168 so the new bridge can
|
||||
/// bind it. Only called after an identity mismatch, so we never kill a bridge that is ours.
|
||||
fn force_free_bridge_port() {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// netstat -ano: last column is the PID for the :14168 LISTENING row.
|
||||
if let Ok(out) = Command::new("netstat").args(["-ano", "-p", "tcp"]).output() {
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
for line in text.lines() {
|
||||
if line.contains(":14168") && line.to_uppercase().contains("LISTENING") {
|
||||
if let Some(pid) = line.split_whitespace().last() {
|
||||
let mut c = Command::new("taskkill");
|
||||
c.args(["/F", "/PID", pid]);
|
||||
c.creation_flags(0x08000000);
|
||||
let _ = c.status();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// lsof prints the listening PIDs; kill -9 each.
|
||||
if let Ok(out) = Command::new("lsof").args(["-ti", "tcp:14168", "-sTCP:LISTEN"]).output() {
|
||||
for pid in String::from_utf8_lossy(&out.stdout).split_whitespace() {
|
||||
let _ = Command::new("kill").args(["-9", pid]).status();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request_bridge_shutdown() {
|
||||
use std::io::{Read, Write};
|
||||
let Ok(mut stream) = TcpStream::connect_timeout(
|
||||
&"127.0.0.1:14168".parse().unwrap(),
|
||||
Duration::from_millis(800),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_millis(600)));
|
||||
let _ = stream.set_write_timeout(Some(Duration::from_millis(600)));
|
||||
let req = b"POST /services/bridge/exit HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
let _ = stream.write_all(req);
|
||||
let _ = stream.read(&mut [0u8; 512]);
|
||||
}
|
||||
|
||||
fn takeover_stale_bridge(project_dir: &str) {
|
||||
if project_dir.is_empty() || !is_bridge_running() {
|
||||
return;
|
||||
}
|
||||
if bridge_identity_matches(project_dir) {
|
||||
return;
|
||||
}
|
||||
eprintln!("[tauri] a different/stale bridge holds 127.0.0.1:14168; taking over");
|
||||
request_bridge_shutdown();
|
||||
let start = Instant::now();
|
||||
while is_bridge_running() && start.elapsed() < Duration::from_secs(10) {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
// Old bridges have no /services/bridge/exit endpoint and ignore the request above — if the
|
||||
// port is still held, force-kill the listener so our fresh bridge can bind it.
|
||||
if is_bridge_running() {
|
||||
eprintln!("[tauri] stale bridge did not exit; force-freeing :14168");
|
||||
force_free_bridge_port();
|
||||
let start = Instant::now();
|
||||
while is_bridge_running() && start.elapsed() < Duration::from_secs(5) {
|
||||
thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_bridge_running() -> bool {
|
||||
TcpStream::connect(("127.0.0.1", 14168)).is_ok()
|
||||
}
|
||||
|
||||
fn wait_for_port(port: u16, timeout: Duration) -> bool {
|
||||
let start = Instant::now();
|
||||
while start.elapsed() < timeout {
|
||||
if TcpStream::connect(("127.0.0.1", port)).is_ok() {
|
||||
return true;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn spawn_bridge_process(python_path: &str, project_dir: &str) -> Result<(), String> {
|
||||
if is_bridge_running() {
|
||||
return Ok(());
|
||||
}
|
||||
let py = PathBuf::from(python_path);
|
||||
let dir = PathBuf::from(project_dir);
|
||||
let script = dir.join("frontends").join("desktop_bridge.py");
|
||||
if !script.exists() {
|
||||
return Err(format!("desktop_bridge.py not found at {:?}", script));
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(&py);
|
||||
cmd.arg(&script).current_dir(&dir);
|
||||
sanitize_bundle_env(&mut cmd);
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
let child = cmd.spawn().map_err(|e| format!("Failed to spawn: {}", e))?;
|
||||
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_bridge_window(app_handle: &tauri::AppHandle) {
|
||||
if let Some(main_win) = app_handle.get_webview_window("main") {
|
||||
let url = tauri::Url::parse("http://127.0.0.1:14168/").unwrap();
|
||||
let _ = main_win.navigate(url);
|
||||
let _ = main_win.show();
|
||||
let _ = main_win.set_focus();
|
||||
}
|
||||
if let Some(setup_win) = app_handle.get_webview_window("setup") {
|
||||
let _ = setup_win.hide();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_bridge_with_config(app_handle: tauri::AppHandle, python_path: String, project_dir: String) -> Result<(), String> {
|
||||
// Save to settings (merge so sibling keys like desktop_shortcut survive).
|
||||
merge_settings(serde_json::json!({"python_path": python_path, "project_dir": project_dir}));
|
||||
|
||||
spawn_bridge_process(&python_path, &project_dir)?;
|
||||
|
||||
// Wait for port
|
||||
if !wait_for_port(14168, Duration::from_secs(20)) {
|
||||
return Err("Bridge did not become ready within 20s".into());
|
||||
}
|
||||
|
||||
show_bridge_window(&app_handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_bridge(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let (python_path, project_dir) = get_or_discover_config();
|
||||
spawn_bridge_process(&python_path, &project_dir)?;
|
||||
if !wait_for_port(14168, Duration::from_secs(20)) {
|
||||
return Err("Bridge did not become ready within 20s".into());
|
||||
}
|
||||
show_bridge_window(&app_handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config() -> (String, String) {
|
||||
get_or_discover_config()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn export_mykey(content: String) -> Result<Option<String>, String> {
|
||||
let path = rfd::FileDialog::new()
|
||||
.set_file_name("mykey.py")
|
||||
.add_filter("Python", &["py"])
|
||||
.save_file();
|
||||
match path {
|
||||
Some(p) => {
|
||||
std::fs::write(&p, content.as_bytes()).map_err(|e| e.to_string())?;
|
||||
Ok(Some(p.to_string_lossy().into_owned()))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let no_autostart = args.iter().any(|a| a == "--no-autostart");
|
||||
let dev_mode = args.iter().any(|a| a == "--dev");
|
||||
|
||||
let project_dir = find_project_dir().unwrap_or_default();
|
||||
let needs_prepare = needs_first_run_prepare(&project_dir);
|
||||
|
||||
takeover_stale_bridge(&project_dir);
|
||||
|
||||
let bridge_ok = is_bridge_running();
|
||||
let mut spawned_bridge = false;
|
||||
// Skip the early spawn when a first-run prepare is required (no venv yet);
|
||||
// the setup thread prepares the env first and then starts the bridge.
|
||||
if !bridge_ok && !no_autostart && !needs_prepare {
|
||||
// Try to start bridge with saved/discovered config
|
||||
let (py_str, dir_str) = get_or_discover_config();
|
||||
let dir = PathBuf::from(&dir_str);
|
||||
let script = dir.join("frontends").join("desktop_bridge.py");
|
||||
if script.exists() {
|
||||
let mut cmd = Command::new(&py_str);
|
||||
cmd.arg(&script).current_dir(&dir);
|
||||
sanitize_bundle_env(&mut cmd);
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x08000000);
|
||||
if let Ok(child) = cmd.spawn() {
|
||||
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
|
||||
spawned_bridge = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.unminimize();
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
}))
|
||||
.invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, shortcut_should_ask, shortcut_decide])
|
||||
.setup(move |app| {
|
||||
// Show the loading window immediately so the first-run prepare isn't a blank screen.
|
||||
// The window starts on loading.html (a local page), so no "connection refused" flash.
|
||||
if let Some(w) = app.get_webview_window("main") {
|
||||
let _ = w.show();
|
||||
}
|
||||
|
||||
let handle = app.handle().clone();
|
||||
let project_dir = project_dir.clone();
|
||||
thread::spawn(move || {
|
||||
// Progress reporter: push status into the loading window (window.gaProgress).
|
||||
let main_win = handle.get_webview_window("main");
|
||||
|
||||
let report = |pct: i32, msg: &str| {
|
||||
if let Some(w) = &main_win {
|
||||
let js = format!(
|
||||
"window.gaProgress && window.gaProgress({}, {})",
|
||||
pct,
|
||||
serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".to_string())
|
||||
);
|
||||
let _ = w.eval(&js);
|
||||
}
|
||||
};
|
||||
|
||||
// First-run (self-contained bundle): prepare the embedded python env offline,
|
||||
// then start the bridge with the freshly created venv.
|
||||
if needs_prepare {
|
||||
report(5, "start");
|
||||
if let Err(e) = run_offline_prepare(&project_dir, &report) {
|
||||
eprintln!("[tauri] first-run prepare failed: {}", e);
|
||||
if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.show(); }
|
||||
if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); }
|
||||
return;
|
||||
}
|
||||
report(95, "starting");
|
||||
if !is_bridge_running() {
|
||||
let (py_str, dir_str) = get_or_discover_config();
|
||||
let dir = PathBuf::from(&dir_str);
|
||||
let script = dir.join("frontends").join("desktop_bridge.py");
|
||||
if script.exists() {
|
||||
let mut cmd = Command::new(&py_str);
|
||||
cmd.arg(&script).current_dir(&dir);
|
||||
sanitize_bundle_env(&mut cmd);
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x08000000);
|
||||
if let Ok(child) = cmd.spawn() {
|
||||
*BRIDGE_PROCESS.lock().unwrap() = Some(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First run (prepare) and cold bridge start may take a while; allow up to 60s.
|
||||
let wait = if needs_prepare || spawned_bridge {
|
||||
Duration::from_secs(60)
|
||||
} else {
|
||||
Duration::from_secs(2)
|
||||
};
|
||||
let bridge_ready = wait_for_port(14168, wait);
|
||||
|
||||
if bridge_ready {
|
||||
// The bridge auto-starts conductor + scheduler itself (on_startup), so we do
|
||||
// NOT probe their ports here: that would self-detect the bridge's own
|
||||
// just-started extras and falsely report "ports busy".
|
||||
if !wait_for_port(14168, Duration::from_secs(15)) {
|
||||
eprintln!("[tauri] bridge not reachable before navigate");
|
||||
if let Some(w) = &main_win {
|
||||
let msg = "无法连接 bridge (127.0.0.1:14168),请关闭程序后重试。";
|
||||
let js = format!(
|
||||
"alert({})",
|
||||
serde_json::to_string(msg).unwrap_or_else(|_| "\"\"".to_string())
|
||||
);
|
||||
let _ = w.eval(&js);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Navigate to the bridge HTTP only after it is ready.
|
||||
if let Some(w) = handle.get_webview_window("main") {
|
||||
if let Ok(url) = tauri::Url::parse("http://127.0.0.1:14168/") {
|
||||
let _ = w.navigate(url);
|
||||
}
|
||||
if dev_mode {
|
||||
w.open_devtools();
|
||||
} else {
|
||||
// Disable F5/F12/Ctrl+R/right-click in production
|
||||
let _ = w.eval(r#"
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'F12' || e.key === 'F5' ||
|
||||
(e.ctrlKey && e.key === 'r') ||
|
||||
(e.ctrlKey && e.shiftKey && e.key === 'I')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
document.addEventListener('contextmenu', function(e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
"#);
|
||||
}
|
||||
let _ = w.show();
|
||||
let _ = w.set_focus();
|
||||
}
|
||||
if let Some(sw) = handle.get_webview_window("setup") { let _ = sw.hide(); }
|
||||
// App is up and reachable: ask-once / self-heal the desktop shortcut.
|
||||
// Runs last so it never blocks the loading/navigation path.
|
||||
maybe_setup_shortcut();
|
||||
} else {
|
||||
// Bridge never came up -> let the user fix paths in the setup window.
|
||||
if let Some(sw) = handle.get_webview_window("setup") {
|
||||
if dev_mode { sw.open_devtools(); }
|
||||
let _ = sw.show();
|
||||
}
|
||||
if let Some(mw) = handle.get_webview_window("main") { let _ = mw.hide(); }
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||
let label = window.label();
|
||||
if label == "main" {
|
||||
// Persistent backend: closing the window does NOT stop the bridge or its
|
||||
// services, so relaunching attaches to the warm backend on 14168.
|
||||
window.app_handle().exit(0);
|
||||
} else if label == "setup" {
|
||||
// Setup closed -> exit if main is not visible
|
||||
if let Some(main_win) = window.app_handle().get_webview_window("main") {
|
||||
if !main_win.is_visible().unwrap_or(false) {
|
||||
window.app_handle().exit(0);
|
||||
}
|
||||
} else {
|
||||
window.app_handle().exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
ga_desktop_lib::run()
|
||||
}
|
||||
Reference in New Issue
Block a user