fix: keep native HoloLake entry responsive
This commit is contained in:
parent
3ff80d583a
commit
7a985a3761
9 changed files with 143 additions and 36 deletions
|
|
@ -33,6 +33,7 @@
|
||||||
"guard:deployment-source": "node scripts/deployment-source-guard.mjs",
|
"guard:deployment-source": "node scripts/deployment-source-guard.mjs",
|
||||||
"test:deployment-source": "node --test scripts/deployment-source-guard.test.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: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: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: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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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/,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
@ -156,11 +156,13 @@ pub fn get_agent_docs_path(app_handle: tauri::AppHandle) -> Result<String, Strin
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_vault_ai_guidance_status(
|
pub async fn get_vault_ai_guidance_status(
|
||||||
vault_path: String,
|
vault_path: String,
|
||||||
) -> Result<WorkspaceAiGuidanceStatus, String> {
|
) -> Result<WorkspaceAiGuidanceStatus, String> {
|
||||||
let vault_path = expand_tilde(&vault_path);
|
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||||
crate::vault::get_ai_guidance_status(vault_path.as_ref())
|
tokio::task::spawn_blocking(move || crate::vault::get_ai_guidance_status(&vault_path))
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Task panicked: {error}"))?
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -448,12 +450,14 @@ mod tests {
|
||||||
assert!(matches!(result, Err(message) if message.contains("Invalid AI agent stream id")));
|
assert!(matches!(result, Err(message) if message.contains("Invalid AI agent stream id")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn guidance_commands_report_and_restore_vault_guidance_files() {
|
async fn guidance_commands_report_and_restore_vault_guidance_files() {
|
||||||
let dir = tempfile::TempDir::new().unwrap();
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
let vault_path = dir.path().to_string_lossy().to_string();
|
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.agents_state, AiGuidanceFileState::Missing);
|
||||||
assert_eq!(initial.claude_state, AiGuidanceFileState::Missing);
|
assert_eq!(initial.claude_state, AiGuidanceFileState::Missing);
|
||||||
assert_eq!(initial.gemini_state, AiGuidanceFileState::Missing);
|
assert_eq!(initial.gemini_state, AiGuidanceFileState::Missing);
|
||||||
|
|
|
||||||
|
|
@ -214,9 +214,13 @@ pub fn git_discard_file(
|
||||||
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn is_git_repo(vault_path: VaultPathArg) -> bool {
|
pub async fn is_git_repo(vault_path: VaultPathArg) -> bool {
|
||||||
let vault_path = expand_tilde(&vault_path);
|
let vault_path = expand_tilde(&vault_path).into_owned();
|
||||||
crate::git::is_inside_work_tree(std::path::Path::new(vault_path.as_ref()))
|
tokio::task::spawn_blocking(move || {
|
||||||
|
crate::git::is_inside_work_tree(std::path::Path::new(&vault_path))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
|
|
@ -509,7 +513,7 @@ mod tests {
|
||||||
let (dir, vault) = create_initialized_vault();
|
let (dir, vault) = create_initialized_vault();
|
||||||
let note = note_path(&dir, "note.md");
|
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();
|
fs::write(dir.path().join("note.md"), "# Updated\n").unwrap();
|
||||||
let modified = get_modified_files(vault.clone(), None).await.unwrap();
|
let modified = get_modified_files(vault.clone(), None).await.unwrap();
|
||||||
|
|
@ -565,8 +569,8 @@ mod tests {
|
||||||
assert!(!documents.join(".git").exists());
|
assert!(!documents.join(".git").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn init_git_repo_allows_named_vault_subfolder_under_documents() {
|
async fn init_git_repo_allows_named_vault_subfolder_under_documents() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let vault = dir.path().join("Documents").join("Tolaria");
|
let vault = dir.path().join("Documents").join("Tolaria");
|
||||||
fs::create_dir_all(&vault).unwrap();
|
fs::create_dir_all(&vault).unwrap();
|
||||||
|
|
@ -575,11 +579,11 @@ mod tests {
|
||||||
|
|
||||||
init_git_repo(vault.clone()).unwrap();
|
init_git_repo(vault.clone()).unwrap();
|
||||||
|
|
||||||
assert!(is_git_repo(vault));
|
assert!(is_git_repo(vault).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn is_git_repo_accepts_vault_nested_inside_parent_worktree() {
|
async fn is_git_repo_accepts_vault_nested_inside_parent_worktree() {
|
||||||
let parent = TempDir::new().unwrap();
|
let parent = TempDir::new().unwrap();
|
||||||
fs::write(parent.path().join("README.md"), "# Parent\n").unwrap();
|
fs::write(parent.path().join("README.md"), "# Parent\n").unwrap();
|
||||||
crate::git::init_repo(parent.path()).unwrap();
|
crate::git::init_repo(parent.path()).unwrap();
|
||||||
|
|
@ -588,7 +592,7 @@ mod tests {
|
||||||
fs::create_dir_all(&nested_vault).unwrap();
|
fs::create_dir_all(&nested_vault).unwrap();
|
||||||
fs::write(nested_vault.join("note.md"), "# Nested\n").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());
|
assert!(!nested_vault.join(".git").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use crate::commands::expand_tilde;
|
use crate::commands::expand_tilde;
|
||||||
use crate::vault::{self, DetectedRename, RenameResult};
|
use crate::vault::{self, DetectedRename, RenameResult};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use super::boundary::{
|
use super::boundary::{
|
||||||
with_boundary, with_existing_path_in_requested_vault, with_validated_path, ValidatedPathMode,
|
with_boundary, with_existing_path_in_requested_vault, with_validated_path, ValidatedPathMode,
|
||||||
|
|
@ -269,9 +269,11 @@ pub fn auto_rename_untitled(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn detect_renames(args: VaultPathCommandArgs) -> Result<Vec<DetectedRename>, String> {
|
pub async fn detect_renames(args: VaultPathCommandArgs) -> Result<Vec<DetectedRename>, String> {
|
||||||
let vault_path = expand_tilde(&args.vault_path);
|
let vault_path = PathBuf::from(expand_tilde(&args.vault_path).into_owned());
|
||||||
vault::detect_renames(Path::new(vault_path.as_ref()))
|
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]
|
#[tauri::command]
|
||||||
|
|
@ -393,8 +395,8 @@ mod tests {
|
||||||
.contains("[[team/Projects/draft]]"));
|
.contains("[[team/Projects/draft]]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn auto_rename_and_detected_rename_commands_route_through_vault() {
|
async fn auto_rename_and_detected_rename_commands_route_through_vault() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let vault = vault_path(&dir);
|
let vault = vault_path(&dir);
|
||||||
let untitled = write_note(&dir, "untitled-note-123.md", "# Project Plan\n");
|
let untitled = write_note(&dir, "untitled-note-123.md", "# Project Plan\n");
|
||||||
|
|
@ -420,6 +422,7 @@ mod tests {
|
||||||
let renames = detect_renames(VaultPathCommandArgs {
|
let renames = detect_renames(VaultPathCommandArgs {
|
||||||
vault_path: vault.clone(),
|
vault_path: vault.clone(),
|
||||||
})
|
})
|
||||||
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(renames.len(), 1);
|
assert_eq!(renames.len(), 1);
|
||||||
assert_eq!(renames[0].old_path, "project-plan.md");
|
assert_eq!(renames[0].old_path, "project-plan.md");
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,13 @@ mod status;
|
||||||
mod upstream;
|
mod upstream;
|
||||||
|
|
||||||
use std::ffi::{OsStr, OsString};
|
use std::ffi::{OsStr, OsString};
|
||||||
use std::io;
|
use std::io::{self, Read};
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::{Command, Output, Stdio};
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
|
@ -98,6 +99,8 @@ const GIT_SHELL_ENV_NAMES: [EnvName<'static>; 8] = [
|
||||||
EnvName::trusted("EMAIL"),
|
EnvName::trusted("EMAIL"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const GIT_WORK_TREE_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct GitLaunchConfig {
|
struct GitLaunchConfig {
|
||||||
program: OsString,
|
program: OsString,
|
||||||
|
|
@ -166,15 +169,52 @@ pub fn is_inside_work_tree(path: impl AsRef<Path>) -> bool {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Ok(output) = git_command_at(path).and_then(|mut command| {
|
let Ok(mut command) = git_command_at(path) else {
|
||||||
command
|
|
||||||
.args(["rev-parse", "--is-inside-work-tree"])
|
|
||||||
.output()
|
|
||||||
}) else {
|
|
||||||
return false;
|
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<Output> {
|
||||||
|
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) {
|
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("https://gitlab.com/owner/repo.git", None);
|
||||||
assert_repo_path("owner/repo", 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,9 +129,9 @@ fn git_head_hash(vault: &Path) -> Option<String> {
|
||||||
|
|
||||||
/// Run a git command in the given directory and return stdout if successful.
|
/// Run a git command in the given directory and return stdout if successful.
|
||||||
fn run_git(vault: &Path, args: &[&str]) -> Option<String> {
|
fn run_git(vault: &Path, args: &[&str]) -> Option<String> {
|
||||||
let output = crate::git::git_command_at(vault)
|
let mut command = crate::git::git_command_at(vault).ok()?;
|
||||||
.and_then(|mut command| command.args(args).output())
|
command.args(args);
|
||||||
.ok()?;
|
let output = crate::git::command_output_with_timeout(&mut command, Duration::from_secs(2))?;
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
|
@ -599,9 +600,14 @@ pub struct DetectedRename {
|
||||||
pub fn detect_renames(vault: &Path) -> Result<Vec<DetectedRename>, String> {
|
pub fn detect_renames(vault: &Path) -> Result<Vec<DetectedRename>, String> {
|
||||||
let output = crate::git::git_command_at(vault)
|
let output = crate::git::git_command_at(vault)
|
||||||
.and_then(|mut command| {
|
.and_then(|mut command| {
|
||||||
command
|
command.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"]);
|
||||||
.args(["diff", "HEAD", "--name-status", "--diff-filter=R", "-M"])
|
crate::git::command_output_with_timeout(&mut command, Duration::from_secs(2))
|
||||||
.output()
|
.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}"))?;
|
.map_err(|e| format!("Failed to run git diff: {e}"))?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue