diff --git a/product-source/hololake-platform/package.json b/product-source/hololake-platform/package.json index 7eeaa20..e65f668 100644 --- a/product-source/hololake-platform/package.json +++ b/product-source/hololake-platform/package.json @@ -33,6 +33,7 @@ "guard:deployment-source": "node scripts/deployment-source-guard.mjs", "test:deployment-source": "node --test scripts/deployment-source-guard.test.mjs", "package:internal:macos": "HOLOLAKE_DISTRIBUTION=personal HOLOLAKE_SOURCE_REPOSITORY_ID=REPO-008 HOLOLAKE_SOURCE_CHANNEL_ID=HLP-CHANNEL-0001 pnpm guard:deployment-source && ./scripts/build-internal-release.sh macos", + "package:local-candidate:macos": "HOLOLAKE_TAURI_CONFIG=src-tauri/tauri.local-candidate.conf.json HOLOLAKE_APP_NAME='HoloLake Era · 本地方向候选 0.4.6' HOLOLAKE_INSTALLER_BASENAME='HoloLake-Era-{version}-Local-Direction-Candidate-Mac-aarch64' ./scripts/build-internal-release.sh macos", "package:internal:windows": "HOLOLAKE_DISTRIBUTION=personal HOLOLAKE_SOURCE_REPOSITORY_ID=REPO-008 HOLOLAKE_SOURCE_CHANNEL_ID=HLP-CHANNEL-0001 pnpm guard:deployment-source && ./scripts/build-internal-release.sh windows", "package:team:macos": "HOLOLAKE_DISTRIBUTION=team HOLOLAKE_SOURCE_REPOSITORY_ID=REPO-008 HOLOLAKE_SOURCE_CHANNEL_ID=HLP-CHANNEL-0001 pnpm guard:deployment-source && HOLOLAKE_TAURI_CONFIG=src-tauri/tauri.team.conf.json HOLOLAKE_APP_NAME='HoloLake Lighthouse Team Beta 0.2.0' HOLOLAKE_INSTALLER_BASENAME='HoloLake-Lighthouse-{version}-Team-Beta-Mac-aarch64' ./scripts/build-internal-release.sh macos", "package:team:windows": "HOLOLAKE_DISTRIBUTION=team HOLOLAKE_SOURCE_REPOSITORY_ID=REPO-008 HOLOLAKE_SOURCE_CHANNEL_ID=HLP-CHANNEL-0001 pnpm guard:deployment-source && HOLOLAKE_TAURI_CONFIG=src-tauri/tauri.team.conf.json HOLOLAKE_INSTALLER_BASENAME='HoloLake-Era-{version}-Team-Foundation-Windows-x64-setup' ./scripts/build-internal-release.sh windows", diff --git a/product-source/hololake-platform/scripts/local-native-candidate.test.mjs b/product-source/hololake-platform/scripts/local-native-candidate.test.mjs new file mode 100644 index 0000000..2772cb8 --- /dev/null +++ b/product-source/hololake-platform/scripts/local-native-candidate.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const readJson = async path => JSON.parse(await readFile(path, 'utf8')) + +test('local native candidate has an isolated macOS identity', async () => { + const [base, candidate, packageJson] = await Promise.all([ + readJson('src-tauri/tauri.conf.json'), + readJson('src-tauri/tauri.local-candidate.conf.json'), + readJson('package.json'), + ]) + + assert.equal(base.version, '0.4.6') + assert.equal(candidate.productName, 'HoloLake Era · 本地方向候选 0.4.6') + assert.equal(candidate.identifier, 'com.guanghulab.hololake.local-candidate') + assert.notEqual(candidate.identifier, base.identifier) + assert.equal(candidate.app.windows[0].title, candidate.productName) + assert.match( + packageJson.scripts['package:local-candidate:macos'], + /tauri\.local-candidate\.conf\.json/, + ) +}) diff --git a/product-source/hololake-platform/src-tauri/src/commands/ai.rs b/product-source/hololake-platform/src-tauri/src/commands/ai.rs index 0666608..8158f9c 100644 --- a/product-source/hololake-platform/src-tauri/src/commands/ai.rs +++ b/product-source/hololake-platform/src-tauri/src/commands/ai.rs @@ -156,11 +156,13 @@ pub fn get_agent_docs_path(app_handle: tauri::AppHandle) -> Result Result { - let vault_path = expand_tilde(&vault_path); - crate::vault::get_ai_guidance_status(vault_path.as_ref()) + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || crate::vault::get_ai_guidance_status(&vault_path)) + .await + .map_err(|error| format!("Task panicked: {error}"))? } #[tauri::command] @@ -448,12 +450,14 @@ mod tests { assert!(matches!(result, Err(message) if message.contains("Invalid AI agent stream id"))); } - #[test] - fn guidance_commands_report_and_restore_vault_guidance_files() { + #[tokio::test] + async fn guidance_commands_report_and_restore_vault_guidance_files() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path().to_string_lossy().to_string(); - let initial = get_vault_ai_guidance_status(vault_path.clone()).unwrap(); + let initial = get_vault_ai_guidance_status(vault_path.clone()) + .await + .unwrap(); assert_eq!(initial.agents_state, AiGuidanceFileState::Missing); assert_eq!(initial.claude_state, AiGuidanceFileState::Missing); assert_eq!(initial.gemini_state, AiGuidanceFileState::Missing); diff --git a/product-source/hololake-platform/src-tauri/src/commands/git.rs b/product-source/hololake-platform/src-tauri/src/commands/git.rs index 09f850b..7d5c862 100644 --- a/product-source/hololake-platform/src-tauri/src/commands/git.rs +++ b/product-source/hololake-platform/src-tauri/src/commands/git.rs @@ -214,9 +214,13 @@ pub fn git_discard_file( #[cfg(desktop)] #[tauri::command] -pub fn is_git_repo(vault_path: VaultPathArg) -> bool { - let vault_path = expand_tilde(&vault_path); - crate::git::is_inside_work_tree(std::path::Path::new(vault_path.as_ref())) +pub async fn is_git_repo(vault_path: VaultPathArg) -> bool { + let vault_path = expand_tilde(&vault_path).into_owned(); + tokio::task::spawn_blocking(move || { + crate::git::is_inside_work_tree(std::path::Path::new(&vault_path)) + }) + .await + .unwrap_or(false) } #[cfg(desktop)] @@ -509,7 +513,7 @@ mod tests { let (dir, vault) = create_initialized_vault(); let note = note_path(&dir, "note.md"); - assert!(is_git_repo(vault.clone())); + assert!(is_git_repo(vault.clone()).await); fs::write(dir.path().join("note.md"), "# Updated\n").unwrap(); let modified = get_modified_files(vault.clone(), None).await.unwrap(); @@ -565,8 +569,8 @@ mod tests { assert!(!documents.join(".git").exists()); } - #[test] - fn init_git_repo_allows_named_vault_subfolder_under_documents() { + #[tokio::test] + async fn init_git_repo_allows_named_vault_subfolder_under_documents() { let dir = TempDir::new().unwrap(); let vault = dir.path().join("Documents").join("Tolaria"); fs::create_dir_all(&vault).unwrap(); @@ -575,11 +579,11 @@ mod tests { init_git_repo(vault.clone()).unwrap(); - assert!(is_git_repo(vault)); + assert!(is_git_repo(vault).await); } - #[test] - fn is_git_repo_accepts_vault_nested_inside_parent_worktree() { + #[tokio::test] + async fn is_git_repo_accepts_vault_nested_inside_parent_worktree() { let parent = TempDir::new().unwrap(); fs::write(parent.path().join("README.md"), "# Parent\n").unwrap(); crate::git::init_repo(parent.path()).unwrap(); @@ -588,7 +592,7 @@ mod tests { fs::create_dir_all(&nested_vault).unwrap(); fs::write(nested_vault.join("note.md"), "# Nested\n").unwrap(); - assert!(is_git_repo(nested_vault.to_string_lossy().into_owned())); + assert!(is_git_repo(nested_vault.to_string_lossy().into_owned()).await); assert!(!nested_vault.join(".git").exists()); } diff --git a/product-source/hololake-platform/src-tauri/src/commands/vault/rename_cmds.rs b/product-source/hololake-platform/src-tauri/src/commands/vault/rename_cmds.rs index a752aa2..2c540cd 100644 --- a/product-source/hololake-platform/src-tauri/src/commands/vault/rename_cmds.rs +++ b/product-source/hololake-platform/src-tauri/src/commands/vault/rename_cmds.rs @@ -1,7 +1,7 @@ use crate::commands::expand_tilde; use crate::vault::{self, DetectedRename, RenameResult}; use serde::Deserialize; -use std::path::Path; +use std::path::{Path, PathBuf}; use super::boundary::{ with_boundary, with_existing_path_in_requested_vault, with_validated_path, ValidatedPathMode, @@ -269,9 +269,11 @@ pub fn auto_rename_untitled( } #[tauri::command] -pub fn detect_renames(args: VaultPathCommandArgs) -> Result, String> { - let vault_path = expand_tilde(&args.vault_path); - vault::detect_renames(Path::new(vault_path.as_ref())) +pub async fn detect_renames(args: VaultPathCommandArgs) -> Result, String> { + let vault_path = PathBuf::from(expand_tilde(&args.vault_path).into_owned()); + tauri::async_runtime::spawn_blocking(move || vault::detect_renames(&vault_path)) + .await + .map_err(|error| format!("Failed to join rename detection task: {error}"))? } #[tauri::command] @@ -393,8 +395,8 @@ mod tests { .contains("[[team/Projects/draft]]")); } - #[test] - fn auto_rename_and_detected_rename_commands_route_through_vault() { + #[tokio::test] + async fn auto_rename_and_detected_rename_commands_route_through_vault() { let dir = TempDir::new().unwrap(); let vault = vault_path(&dir); let untitled = write_note(&dir, "untitled-note-123.md", "# Project Plan\n"); @@ -420,6 +422,7 @@ mod tests { let renames = detect_renames(VaultPathCommandArgs { vault_path: vault.clone(), }) + .await .unwrap(); assert_eq!(renames.len(), 1); assert_eq!(renames[0].old_path, "project-plan.md"); diff --git a/product-source/hololake-platform/src-tauri/src/git/mod.rs b/product-source/hololake-platform/src-tauri/src/git/mod.rs index 6d049ce..c27464d 100644 --- a/product-source/hololake-platform/src-tauri/src/git/mod.rs +++ b/product-source/hololake-platform/src-tauri/src/git/mod.rs @@ -20,12 +20,13 @@ mod status; mod upstream; use std::ffi::{OsStr, OsString}; -use std::io; +use std::io::{self, Read}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Output, Stdio}; use std::sync::OnceLock; +use std::time::{Duration, Instant}; #[cfg(test)] use std::cell::RefCell; @@ -98,6 +99,8 @@ const GIT_SHELL_ENV_NAMES: [EnvName<'static>; 8] = [ EnvName::trusted("EMAIL"), ]; +const GIT_WORK_TREE_PROBE_TIMEOUT: Duration = Duration::from_secs(2); + #[derive(Clone)] struct GitLaunchConfig { program: OsString, @@ -166,15 +169,52 @@ pub fn is_inside_work_tree(path: impl AsRef) -> bool { return false; } - let Ok(output) = git_command_at(path).and_then(|mut command| { - command - .args(["rev-parse", "--is-inside-work-tree"]) - .output() - }) else { + let Ok(mut command) = git_command_at(path) else { return false; }; + command + .args(["rev-parse", "--is-inside-work-tree", "--show-toplevel"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); - output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true" + command_output_with_timeout(&mut command, GIT_WORK_TREE_PROBE_TIMEOUT) + .is_some_and(|output| output.status.success()) +} + +pub(crate) fn command_output_with_timeout( + command: &mut Command, + timeout: Duration, +) -> Option { + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .ok()?; + let deadline = Instant::now() + timeout; + + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + child.stdout.take()?.read_to_end(&mut stdout).ok()?; + child.stderr.take()?.read_to_end(&mut stderr).ok()?; + return Some(Output { + status, + stdout, + stderr, + }); + } + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) | Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } } fn apply_git_shell_env(command: &mut Command) { @@ -1147,4 +1187,18 @@ mod tests { assert_repo_path("https://gitlab.com/owner/repo.git", None); assert_repo_path("owner/repo", None); } + + #[cfg(unix)] + #[test] + fn test_wait_for_command_kills_a_hung_process_at_the_deadline() { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 5"]); + let started = std::time::Instant::now(); + + let status = + command_output_with_timeout(&mut command, std::time::Duration::from_millis(40)); + + assert!(status.is_none()); + assert!(started.elapsed() < std::time::Duration::from_secs(2)); + } } diff --git a/product-source/hololake-platform/src-tauri/src/vault/cache.rs b/product-source/hololake-platform/src-tauri/src/vault/cache.rs index b4e67a1..0cfb2e8 100644 --- a/product-source/hololake-platform/src-tauri/src/vault/cache.rs +++ b/product-source/hololake-platform/src-tauri/src/vault/cache.rs @@ -129,9 +129,9 @@ fn git_head_hash(vault: &Path) -> Option { /// Run a git command in the given directory and return stdout if successful. fn run_git(vault: &Path, args: &[&str]) -> Option { - let output = crate::git::git_command_at(vault) - .and_then(|mut command| command.args(args).output()) - .ok()?; + let mut command = crate::git::git_command_at(vault).ok()?; + command.args(args); + let output = crate::git::command_output_with_timeout(&mut command, Duration::from_secs(2))?; if !output.status.success() { return None; } diff --git a/product-source/hololake-platform/src-tauri/src/vault/rename.rs b/product-source/hololake-platform/src-tauri/src/vault/rename.rs index 1769467..291ad22 100644 --- a/product-source/hololake-platform/src-tauri/src/vault/rename.rs +++ b/product-source/hololake-platform/src-tauri/src/vault/rename.rs @@ -4,6 +4,7 @@ use std::collections::HashSet; use std::fs; use std::io::Write; use std::path::Path; +use std::time::Duration; use tempfile::NamedTempFile; use walkdir::WalkDir; @@ -599,9 +600,14 @@ pub struct DetectedRename { pub fn detect_renames(vault: &Path) -> Result, String> { let output = crate::git::git_command_at(vault) .and_then(|mut command| { - command - .args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"]) - .output() + command.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"]); + crate::git::command_output_with_timeout(&mut command, Duration::from_secs(2)) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "git rename detection exceeded its deadline", + ) + }) }) .map_err(|e| format!("Failed to run git diff: {e}"))?; diff --git a/product-source/hololake-platform/src-tauri/tauri.local-candidate.conf.json b/product-source/hololake-platform/src-tauri/tauri.local-candidate.conf.json new file mode 100644 index 0000000..416d4eb --- /dev/null +++ b/product-source/hololake-platform/src-tauri/tauri.local-candidate.conf.json @@ -0,0 +1,12 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "HoloLake Era · 本地方向候选 0.4.6", + "identifier": "com.guanghulab.hololake.local-candidate", + "app": { + "windows": [ + { + "title": "HoloLake Era · 本地方向候选 0.4.6" + } + ] + } +}