mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 01:02:16 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3628b0bcf | ||
|
|
a9d61df041 | ||
|
|
c647aa28a0 | ||
|
|
6cb052f732 | ||
|
|
33941cd3f4 | ||
|
|
1291925884 | ||
|
|
ae35a7c487 | ||
|
|
2a5e77a1c1 | ||
|
|
d2ed96658d | ||
|
|
c08c4d323f |
No files matched your search
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.7.0-preview.1"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.7.0-preview.1"
|
||||
version = "0.7.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
+3
-3
@@ -38,7 +38,7 @@ Deleted `/home` data cannot be recovered. OS updates may require manual interven
|
||||
|
||||
Device and installer share the repository with independent versions/tags/workflows:
|
||||
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.7.0-preview.1`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Device: root Cargo version, `v<version>` (currently `v0.7.1`), `.github/workflows/release.yml`, Linux ARM64 and pinned CEF.
|
||||
- Installer: `installer/Cargo.toml`, `installer-v<version>` (currently `installer-v0.4.1-preview.10`), `.github/workflows/installer-release.yml`, Linux x64/ARM64, Windows x64, macOS Intel/Apple Silicon, pinned GPUI Kit.
|
||||
|
||||
Versions need not match or ship together. Update the relevant Cargo.lock with version changes. Installer package/macOS versions use the installer version.
|
||||
@@ -47,8 +47,8 @@ Both workflows cache dependencies/builds by product/platform/toolchain/configura
|
||||
|
||||
```bash
|
||||
# After committing and pushing the intended source:
|
||||
git tag v0.7.0-preview.1
|
||||
git push origin v0.7.0-preview.1
|
||||
git tag v0.7.1
|
||||
git push origin v0.7.1
|
||||
# Independent installer release:
|
||||
git tag installer-v0.4.1-preview.10
|
||||
git push origin installer-v0.4.1-preview.10
|
||||
|
||||
@@ -48,7 +48,7 @@ CEF 按内容哈希保存在 `/home/.framely/cef/<运行库标识>`,更新时
|
||||
|
||||
Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标签和发布工作流:
|
||||
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.7.0-preview.1`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- Framely:版本由根 `Cargo.toml` 定义,标签为 `v<版本>`(当前 `v0.7.1`);`.github/workflows/release.yml` 只构建 Frame Linux ARM64 发行,使用锁定的 CEF 并执行自动检查。
|
||||
- 安装器:版本由 `installer/Cargo.toml` 定义,标签为 `installer-v<版本>`(当前 `installer-v0.4.1-preview.10`);`.github/workflows/installer-release.yml` 只构建 Linux x64/ARM64、Windows x64、macOS x64/ARM64 的原生安装器,使用锁定的 GPUI Kit。
|
||||
|
||||
两个版本不需要相同,也不需要同时发布。修改各自版本时同步对应的 `Cargo.lock`;安装器包名及 macOS 应用版本使用安装器版本。
|
||||
@@ -59,8 +59,8 @@ Framely 和桌面安装器保留在同一仓库,使用独立的版本号、标
|
||||
|
||||
```bash
|
||||
# 提交并推送代码后,选择需要发布的产品
|
||||
git tag v0.7.0-preview.1
|
||||
git push origin v0.7.0-preview.1
|
||||
git tag v0.7.1
|
||||
git push origin v0.7.1
|
||||
|
||||
# 独立发布安装器,不触发 Framely 构建
|
||||
git tag installer-v0.4.1-preview.10
|
||||
|
||||
+398
-13
@@ -107,6 +107,45 @@ struct Database {
|
||||
#[serde(default)]
|
||||
records: BTreeMap<String, Record>,
|
||||
}
|
||||
#[derive(Default, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct StorageLocations {
|
||||
#[serde(default)]
|
||||
apk: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
contexts: Option<PathBuf>,
|
||||
}
|
||||
fn storage_config_path(home: &Path) -> PathBuf {
|
||||
home.join(".local/share/framely/storage.json")
|
||||
}
|
||||
fn storage_journal_path(home: &Path) -> PathBuf {
|
||||
home.join(".local/share/framely/storage-migration.json")
|
||||
}
|
||||
fn storage_config(home: &Path) -> Result<StorageLocations> {
|
||||
let path = storage_config_path(home);
|
||||
if !path.exists() { return Ok(StorageLocations::default()); }
|
||||
let meta = fs::symlink_metadata(&path)?;
|
||||
ensure!(meta.is_file() && !meta.file_type().is_symlink() && meta.uid() == unsafe { libc::geteuid() }, "无效 APK 存储设置文件");
|
||||
let config: StorageLocations = serde_json::from_slice(&fs::read(path)?)?;
|
||||
for location in [&config.apk, &config.contexts].into_iter().flatten() {
|
||||
ensure!(location.is_absolute() && location.is_dir() && !fs::symlink_metadata(location)?.file_type().is_symlink(), "配置的存储目录不可用:{}", location.display());
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
fn save_storage_config(home: &Path, config: &StorageLocations) -> Result<()> {
|
||||
let path = storage_config_path(home);
|
||||
fs::create_dir_all(path.parent().unwrap())?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
fs::write(&tmp, serde_json::to_vec(config)?)?;
|
||||
fs::set_permissions(&tmp, fs::Permissions::from_mode(0o600))?;
|
||||
fs::File::open(&tmp)?.sync_all()?;
|
||||
fs::rename(tmp, &path)?;
|
||||
fs::File::open(path.parent().unwrap())?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
fn contexts_root(home: &Path) -> PathBuf {
|
||||
storage_config(home).ok().and_then(|c| c.contexts).unwrap_or_else(|| home.join(".local/share/lepton/contexts"))
|
||||
}
|
||||
impl Default for Database {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -180,9 +219,10 @@ fn safe(s: &str) -> bool {
|
||||
&& s != ".."
|
||||
}
|
||||
fn root(home: &Path) -> PathBuf {
|
||||
home.join(".local/share/framely/apk-manager")
|
||||
storage_config(home).ok().and_then(|c| c.apk).unwrap_or_else(|| home.join(".local/share/framely/apk-manager"))
|
||||
}
|
||||
fn init(home: &Path) -> Result<PathBuf> {
|
||||
storage_config(home)?;
|
||||
let r = root(home);
|
||||
for p in [
|
||||
home.join(".local"),
|
||||
@@ -208,6 +248,7 @@ fn init(home: &Path) -> Result<PathBuf> {
|
||||
Ok(r)
|
||||
}
|
||||
fn load(home: &Path) -> Result<Database> {
|
||||
storage_config(home)?;
|
||||
let p = root(home).join("state.json");
|
||||
if !p.exists() {
|
||||
return Ok(Database::default());
|
||||
@@ -363,13 +404,17 @@ fn validate_baked(p: &Path) -> Result<PathBuf> {
|
||||
}
|
||||
fn containers(home: &Path, db: &Database) -> Vec<Container> {
|
||||
let mut out = Vec::new();
|
||||
let base = home.join(".local/share/lepton/contexts");
|
||||
if let Ok(entries) = fs::read_dir(&base) {
|
||||
let managed_base = contexts_root(home);
|
||||
let legacy_base = home.join(".local/share/lepton/contexts");
|
||||
for base in [&managed_base, &legacy_base] {
|
||||
if let Ok(entries) = fs::read_dir(base) {
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if !safe(&name) {
|
||||
continue;
|
||||
}
|
||||
if base == &legacy_base && managed_base != legacy_base && db.owned_contexts.contains(&name) && managed_base.join(&name).exists() { continue; }
|
||||
if out.iter().any(|c: &Container| c.name == name) { continue; }
|
||||
let baked = e.path().join("baked");
|
||||
if let Ok(baked) = validate_baked(&baked) {
|
||||
out.push(Container {
|
||||
@@ -383,9 +428,10 @@ fn containers(home: &Path, db: &Database) -> Vec<Container> {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in &db.owned_contexts {
|
||||
if safe(id) && !out.iter().any(|c| c.id == *id) {
|
||||
let baked = base.join(id).join("baked");
|
||||
let baked = managed_base.join(id).join("baked");
|
||||
if baked.parent().is_some_and(|p| p.is_dir())
|
||||
&& !fs::symlink_metadata(&baked).is_ok_and(|m| m.file_type().is_symlink())
|
||||
{
|
||||
@@ -592,7 +638,7 @@ fn automatic_install_context(
|
||||
return Ok(id);
|
||||
}
|
||||
if !records.is_empty()
|
||||
|| fs::symlink_metadata(home.join(".local/share/lepton/contexts").join(&id)).is_ok()
|
||||
|| fs::symlink_metadata(contexts_root(home).join(&id)).is_ok()
|
||||
{
|
||||
continue; // Preserve retained records and unreadable/dangling paths.
|
||||
}
|
||||
@@ -950,6 +996,238 @@ pub fn list(home: &Path) -> Result<Value> {
|
||||
json!({"apps":apps.iter().map(|a| { let mut v=serde_json::to_value(a).unwrap(); v["steamLaunch"]=json!(db.records.get(&a.id).is_none_or(steam_shortcuts::wanted)); v["steamRegistered"]=json!(db.records.get(&a.id).is_some_and(|r|r.steam_launch));v["steamRegistrationError"]=json!(db.records.get(&a.id).and_then(|r|r.steam_registration_error.as_ref())); v }).collect::<Vec<_>>(),"containers":cs.iter().map(|c| { let mut v=serde_json::to_value(c).unwrap(); v["acceptsAdditionalApps"]=json!(false); v }).collect::<Vec<_>>(),"warnings":warnings,"roots":db.roots,"available":runner(home).is_ok(),"retainSteamEntries":db.retain_steam_entries,"gamepadEnabled":db.gamepad_enabled,"gamepadSource":db.gamepad_source,"gamepadRumble":db.gamepad_rumble,"gamepadTriggerThreshold":db.gamepad_trigger_threshold,"autoStopContainer":db.auto_stop_container,"stopContainerOnClose":db.stop_container_on_close}),
|
||||
)
|
||||
}
|
||||
pub fn storage_status(home: &Path) -> Result<Value> {
|
||||
let config = storage_config(home)?;
|
||||
let apk = config.apk.unwrap_or_else(|| home.join(".local/share/framely/apk-manager"));
|
||||
let contexts = config.contexts.unwrap_or_else(|| home.join(".local/share/lepton/contexts"));
|
||||
let db = load(home)?;
|
||||
let mut context_bytes = 0u64;
|
||||
let mut contexts_incomplete = false;
|
||||
let mut context_details = Vec::new();
|
||||
for id in &db.owned_contexts {
|
||||
if safe(id) {
|
||||
let (size, incomplete) = crate::storage_location::disk_bytes(&contexts.join(id))?;
|
||||
context_bytes = context_bytes.saturating_add(size);
|
||||
contexts_incomplete |= incomplete;
|
||||
context_details.push(json!({"id":id,"name":id,"bytes":size,"sizeIncomplete":incomplete}));
|
||||
}
|
||||
}
|
||||
let (apk_bytes, apk_incomplete) = crate::storage_location::disk_bytes(&apk)?;
|
||||
let mut apk_details = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(apk.join("apks")) {
|
||||
for entry in entries {
|
||||
let entry = match entry { Ok(entry) => entry, Err(_) => continue };
|
||||
let path = entry.path();
|
||||
if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { continue; }
|
||||
let key = entry.file_name();
|
||||
let record = db.records.values().find(|record| hash(&record.id) == key.to_string_lossy());
|
||||
let (size, incomplete) = crate::storage_location::disk_bytes(&path)?;
|
||||
let name = record.map_or_else(|| key.to_string_lossy().into_owned(), |record| record.metadata.name.clone());
|
||||
let id = record.map_or_else(|| key.to_string_lossy().into_owned(), |record| record.id.clone());
|
||||
apk_details.push(json!({"id":id,"name":name,"bytes":size,"sizeIncomplete":incomplete}));
|
||||
}
|
||||
}
|
||||
apk_details.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
let apk_device = fs::metadata(&apk).ok().map(|m| m.dev());
|
||||
let context_device = fs::metadata(&contexts).ok().map(|m| m.dev());
|
||||
let volume = crate::storage_location::volume(home)?;
|
||||
let journal = fs::read(storage_journal_path(home)).ok().and_then(|b|serde_json::from_slice::<Value>(&b).ok());
|
||||
let committed = journal.as_ref().is_some_and(|v| v["target"] == json!(if v["kind"] == "apk" { &apk } else { &contexts }));
|
||||
if committed {
|
||||
let _ = fs::remove_file(storage_journal_path(home));
|
||||
}
|
||||
Ok(json!({"apk":{"path":apk,"bytes":apk_bytes,"sizeIncomplete":apk_incomplete,"device":apk_device,"details":apk_details},"contexts":{"path":contexts,"bytes":context_bytes,"sizeIncomplete":contexts_incomplete,"device":context_device,"details":context_details},"volume":volume,"interrupted":if committed {None} else {journal}}))
|
||||
}
|
||||
pub fn migrate_storage(home: &Path, kind: &str, target: &Path, cancel: Cancellation, progress: Arc<dyn Fn(Value) + Send + Sync>) -> Result<Value> {
|
||||
ensure!(matches!(kind, "apk" | "contexts"), "无效存储类型");
|
||||
let _guard = MUTATION.try_lock().map_err(|_| anyhow::anyhow!("已有 APK 操作正在运行"))?;
|
||||
cancel.check()?;
|
||||
let config = storage_config(home)?;
|
||||
let old_apk = init(home)?;
|
||||
let lock_file = fs::OpenOptions::new().write(true).create(true).mode(0o600).open(old_apk.join("operation.lock"))?;
|
||||
ensure!(unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0, "已有 APK 操作正在运行");
|
||||
let db = load(home)?;
|
||||
let source = if kind == "apk" { old_apk } else { contexts_root(home) };
|
||||
let journal_path = storage_journal_path(home);
|
||||
if let Ok(bytes) = fs::read(&journal_path) {
|
||||
let record: Value = serde_json::from_slice(&bytes)?;
|
||||
if record["target"] == json!(source) { fs::remove_file(&journal_path)?; }
|
||||
}
|
||||
let (target, resume) = if journal_path.exists() {
|
||||
let record: Value = serde_json::from_slice(&fs::read(&journal_path)?)?;
|
||||
ensure!(record["kind"] == kind && record["source"] == json!(source), "存储迁移来源已改变,请检查原目录");
|
||||
if record["target"] == json!(target) {
|
||||
let verified = if target.exists() { if kind == "apk" { crate::storage_location::verified_equal(&source, target)? } else { crate::storage_location::verified_selected(&source, target, &db.owned_contexts, false)? } } else { false };
|
||||
ensure!(!target.exists() || verified || (target.is_dir() && fs::read_dir(target)?.next().is_none()), "上次迁移副本已与原目录不同,请选择新的空目录");
|
||||
(target.to_path_buf(), verified)
|
||||
} else { (crate::storage_location::validate_target(&source, target, false)?, false) }
|
||||
} else {
|
||||
(crate::storage_location::validate_target(&source, target, false)?, false)
|
||||
};
|
||||
let log = root(home).join("logs/storage-migration.log");
|
||||
let owned: Vec<Container> = containers(home, &db).into_iter()
|
||||
.filter(|c| db.owned_contexts.contains(&c.id)).collect();
|
||||
let active: Vec<Container> = owned.iter().filter(|c| c.running).cloned().collect();
|
||||
let result = (|| -> Result<Value> {
|
||||
crate::storage_location::write_journal(&journal_path, &json!({"kind":kind,"source":source,"target":target}))?;
|
||||
progress(json!({"step":"stopping"}));
|
||||
for c in &owned { stop(c, &log)?; }
|
||||
cancel.check()?;
|
||||
if resume { progress(json!({"step":"verifying"})); }
|
||||
else if kind == "apk" {
|
||||
crate::storage_location::copy_verified(&source, &target, |step| progress(json!({"step":step})))?;
|
||||
} else {
|
||||
crate::storage_location::copy_selected(&source, &target, &db.owned_contexts, false, |step| progress(json!({"step":step})))?;
|
||||
}
|
||||
cancel.check()?;
|
||||
let mut next = config.clone();
|
||||
if kind == "apk" { next.apk = Some(target.clone()); } else { next.contexts = Some(target.clone()); }
|
||||
save_storage_config(home, &next)?;
|
||||
progress(json!({"step":"restarting"}));
|
||||
let new_log = root(home).join("logs/storage-migration.log");
|
||||
let mut started = Vec::new();
|
||||
for c in &active {
|
||||
let mut relocated = c.clone();
|
||||
if kind == "contexts" { relocated.baked = target.join(&c.id).join("baked"); }
|
||||
if let Err(error) = start_container(home, &relocated, None, false, &new_log) {
|
||||
for opened in &started { let _ = stop(opened, &new_log); }
|
||||
let _ = save_storage_config(home, &config);
|
||||
for prior in &active { let _ = start_container(home, prior, None, false, &log); }
|
||||
return Err(error).context("迁移后容器启动失败,已恢复原位置");
|
||||
}
|
||||
started.push(relocated);
|
||||
}
|
||||
fs::remove_file(&journal_path)?;
|
||||
progress(json!({"step":"cleaning"}));
|
||||
let mut warnings = Vec::new();
|
||||
if kind == "apk" {
|
||||
if let Err(error) = crate::storage_location::remove_tree(&source) { warnings.push(error.to_string()); }
|
||||
} else {
|
||||
for id in &db.owned_contexts {
|
||||
if safe(id) && source.join(id).exists() {
|
||||
if let Err(error) = crate::storage_location::remove_tree(&source.join(id)) { warnings.push(error.to_string()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"path":target,"cleanupWarnings":warnings}))
|
||||
})();
|
||||
if result.is_err() {
|
||||
for c in &active { let _ = start_container(home, c, None, false, &log); }
|
||||
}
|
||||
result
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod storage_migration_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn storage_status_lists_apk_and_managed_container_sizes() {
|
||||
let _serial = TEST_SERIAL.lock().unwrap();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let home = temp.path().join("home");
|
||||
fs::create_dir(&home).unwrap();
|
||||
let apk_root = init(&home).unwrap();
|
||||
fs::create_dir_all(apk_root.join("apks/example")).unwrap();
|
||||
fs::write(apk_root.join("apks/example/base.apk"), vec![1; 8192]).unwrap();
|
||||
let container = contexts_root(&home).join("managed");
|
||||
fs::create_dir_all(&container).unwrap();
|
||||
fs::write(container.join("data"), vec![2; 8192]).unwrap();
|
||||
let mut db = load(&home).unwrap();
|
||||
db.owned_contexts.push("managed".into());
|
||||
save(&home, &db).unwrap();
|
||||
let status = storage_status(&home).unwrap();
|
||||
assert_eq!(status["apk"]["details"][0]["id"], "example");
|
||||
assert!(status["apk"]["details"][0]["bytes"].as_u64().unwrap() > 0);
|
||||
assert_eq!(status["contexts"]["details"][0]["id"], "managed");
|
||||
assert!(status["contexts"]["details"][0]["bytes"].as_u64().unwrap() > 0);
|
||||
assert!(status["volume"]["total"].as_u64().unwrap() > 0);
|
||||
}
|
||||
#[test]
|
||||
fn apk_and_owned_contexts_move_independently() {
|
||||
let _serial = TEST_SERIAL.lock().unwrap();
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let home = temp.path().join("home");
|
||||
fs::create_dir(&home).unwrap();
|
||||
let original_apk = init(&home).unwrap();
|
||||
fs::write(original_apk.join("apks/example.apk"), b"apk").unwrap();
|
||||
let apk_work = original_apk.join("recovery/old/baked/app_workdir/work");
|
||||
fs::create_dir_all(&apk_work).unwrap();
|
||||
fs::write(apk_work.join("overlay-state"), b"private").unwrap();
|
||||
fs::set_permissions(&apk_work, fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let original_contexts = contexts_root(&home);
|
||||
let baked = original_contexts.join("managed/baked/data_overlay/system");
|
||||
fs::create_dir_all(&baked).unwrap();
|
||||
fs::write(baked.join("packages.xml"), b"<packages/>").unwrap();
|
||||
let context_work = original_contexts.join("managed/baked/data_workdir/work");
|
||||
fs::create_dir_all(&context_work).unwrap();
|
||||
fs::write(context_work.join("overlay-state"), b"private").unwrap();
|
||||
fs::set_permissions(&context_work, fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let external = original_contexts.join("external/baked/data_overlay/system");
|
||||
fs::create_dir_all(&external).unwrap();
|
||||
fs::write(external.join("packages.xml"), b"<packages/>").unwrap();
|
||||
let mut db = load(&home).unwrap();
|
||||
db.owned_contexts.push("managed".into());
|
||||
save(&home, &db).unwrap();
|
||||
let apk_target = temp.path().join("apk-new");
|
||||
migrate_storage(&home,"apk",&apk_target,Cancellation::default(),Arc::new(|_|{})).unwrap();
|
||||
assert_eq!(root(&home), apk_target);
|
||||
assert_eq!(fs::read(root(&home).join("apks/example.apk")).unwrap(), b"apk");
|
||||
assert!(!original_apk.exists());
|
||||
assert_eq!(fs::metadata(apk_target.join("recovery/old/baked/app_workdir/work")).unwrap().permissions().mode() & 0o777, 0);
|
||||
assert!(original_contexts.join("managed/baked").is_dir());
|
||||
let context_target = temp.path().join("android-new");
|
||||
migrate_storage(&home,"contexts",&context_target,Cancellation::default(),Arc::new(|_|{})).unwrap();
|
||||
assert_eq!(contexts_root(&home), context_target);
|
||||
assert!(context_target.join("managed/baked/data_overlay/system/packages.xml").is_file());
|
||||
assert!(!context_target.join("external").exists());
|
||||
assert!(original_contexts.join("external/baked").is_dir());
|
||||
assert!(!original_contexts.join("managed").exists());
|
||||
let found = containers(&home,&load(&home).unwrap());
|
||||
assert!(found.iter().any(|c|c.id=="managed" && c.baked.starts_with(&context_target)));
|
||||
assert!(found.iter().any(|c|c.id=="external" && c.baked.starts_with(&original_contexts)));
|
||||
fs::set_permissions(apk_target.join("recovery/old/baked/app_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::set_permissions(context_target.join("managed/baked/data_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn interrupted_apk_copy_resumes_without_touching_original() {
|
||||
let _serial = TEST_SERIAL.lock().unwrap();
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let home = temp.path().join("home");
|
||||
fs::create_dir(&home).unwrap();
|
||||
let source = init(&home).unwrap();
|
||||
fs::write(source.join("apks/keep.apk"), b"saved apk").unwrap();
|
||||
fs::write(source.join("operation.lock"), b"").unwrap();
|
||||
let target = temp.path().join("apk-resume");
|
||||
crate::storage_location::copy_verified(&source,&target,|_|{}).unwrap();
|
||||
crate::storage_location::write_journal(&storage_journal_path(&home),&json!({"kind":"apk","source":source,"target":target})).unwrap();
|
||||
assert_eq!(storage_status(&home).unwrap()["interrupted"]["target"],json!(target));
|
||||
migrate_storage(&home,"apk",&target,Cancellation::default(),Arc::new(|_|{})).unwrap();
|
||||
assert_eq!(root(&home),target);
|
||||
assert_eq!(fs::read(target.join("apks/keep.apk")).unwrap(),b"saved apk");
|
||||
assert!(!storage_journal_path(&home).exists());
|
||||
}
|
||||
#[test]
|
||||
fn storage_status_survives_unreadable_overlay_workdirs() {
|
||||
if unsafe { libc::geteuid() } == 0 { return; }
|
||||
let _serial = TEST_SERIAL.lock().unwrap();
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let home = temp.path().join("home");
|
||||
fs::create_dir(&home).unwrap();
|
||||
let apk_root = init(&home).unwrap();
|
||||
let apk_work = apk_root.join("recovery/old/baked/app_workdir/work");
|
||||
fs::create_dir_all(&apk_work).unwrap();
|
||||
let context_work = contexts_root(&home).join("managed/baked/data_workdir/work");
|
||||
fs::create_dir_all(&context_work).unwrap();
|
||||
let mut db = load(&home).unwrap();
|
||||
db.owned_contexts.push("managed".into());
|
||||
save(&home, &db).unwrap();
|
||||
for path in [&apk_work, &context_work] { fs::set_permissions(path, fs::Permissions::from_mode(0o000)).unwrap(); }
|
||||
let status = storage_status(&home);
|
||||
for path in [&apk_work, &context_work] { fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); }
|
||||
let status = status.unwrap();
|
||||
assert_eq!(status["apk"]["sizeIncomplete"], true);
|
||||
assert_eq!(status["contexts"]["sizeIncomplete"], true);
|
||||
}
|
||||
}
|
||||
pub fn launcher(home: &Path) -> Vec<Value> {
|
||||
let Ok(db) = load(home) else { return vec![] };
|
||||
let cs = containers(home, &db);
|
||||
@@ -1194,6 +1472,10 @@ fn direct_launch_script(source: &str) -> Result<String> {
|
||||
"Unsupported Lepton entry script; cannot safely configure the APK display"
|
||||
);
|
||||
let hooks = r#"
|
||||
# Lepton 3 calls this helper from remove_prefix, independently of
|
||||
# LEPTON_NO_CLEANUP. Preserve installed data for both the package-manager
|
||||
# container and native Steam launches; temporary prefix removal still runs.
|
||||
function prepare_baked_data_for_removal() { :; }
|
||||
# Preserve downloaded Android resources in development contexts. Lepton's
|
||||
# single-app launcher deletes media/0 before creating its external-storage link.
|
||||
function framely_prepare_media() {
|
||||
@@ -1228,6 +1510,24 @@ function framely_prepare_media() {
|
||||
mkdir -p -- "$media"
|
||||
fi
|
||||
}
|
||||
function framely_link_host_directory() {
|
||||
local target="$1" source="$2"
|
||||
if [[ -L "$target" ]]; then
|
||||
[[ "$(readlink "$target")" == "$source" ]] || { echo "Unexpected Android storage link: $target" >&2; return 64; }
|
||||
rm -f -- "$target" || return
|
||||
elif [[ -d "$target" ]]; then
|
||||
# An app may replace the link with a real directory. Keep its files
|
||||
# visible inside Android instead of removing or hiding them.
|
||||
if [[ -n "$(find "$target" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then
|
||||
return 0
|
||||
fi
|
||||
rmdir -- "$target" || return
|
||||
elif [[ -e "$target" ]]; then
|
||||
echo "Unexpected Android storage entry: $target" >&2
|
||||
return 64
|
||||
fi
|
||||
ln -s -- "$source" "$target"
|
||||
}
|
||||
for framely_mount_hook in setup_mounts setup_podman_mounts; do
|
||||
declare -F "$framely_mount_hook" >/dev/null || continue
|
||||
framely_mount_body="$(declare -f "$framely_mount_hook")"
|
||||
@@ -1240,6 +1540,21 @@ for framely_mount_hook in setup_mounts setup_podman_mounts; do
|
||||
fi
|
||||
if [[ "$framely_mount_body" == *"$framely_media_remove"* ]]; then
|
||||
framely_mount_body="${framely_mount_body//"$framely_media_remove"/framely_prepare_media || return}"
|
||||
if [[ "$framely_mount_hook" == setup_mounts ]]; then
|
||||
for framely_host_pair in 'Documents:Documents' 'Download:Downloads' 'Movies:Videos'; do
|
||||
framely_host_target="${framely_host_pair%%:*}"
|
||||
framely_host_source="${framely_host_pair#*:}"
|
||||
framely_host_remove="rm -rf \"\${TARGET_PATH}/$framely_host_target\""
|
||||
framely_host_link="ln -s \"\${HOME}/$framely_host_source\" \"\${TARGET_PATH}/$framely_host_target\""
|
||||
if [[ "$framely_mount_body" == *"$framely_host_remove"* || "$framely_mount_body" == *"$framely_host_link"* ]]; then
|
||||
[[ "$framely_mount_body" == *"$framely_host_remove"* && "$framely_mount_body" == *"$framely_host_link"* ]] || { echo "Unsupported Lepton host storage links" >&2; exit 64; }
|
||||
framely_host_replacement="framely_link_host_directory \"\${TARGET_PATH}/$framely_host_target\" \"\${HOME}/$framely_host_source\" || return"
|
||||
framely_mount_body="${framely_mount_body//"$framely_host_remove"/$framely_host_replacement}"
|
||||
framely_mount_body="${framely_mount_body//"$framely_host_link"/:}"
|
||||
fi
|
||||
done
|
||||
unset framely_host_pair framely_host_target framely_host_source framely_host_remove framely_host_link framely_host_replacement
|
||||
fi
|
||||
if [[ "${FRAMELY_NATIVE_LAUNCH:-false}" == true ]]; then
|
||||
framely_native_link='ln -s "${STEAM_COMPAT_DATA_PATH}/external"'
|
||||
framely_preserved_link='ln -s "${FRAMELY_EXTERNAL_MEDIA_DIR:?}"'
|
||||
@@ -1846,17 +2161,21 @@ fn snapshot_inventory(
|
||||
Ok(())
|
||||
}
|
||||
fn recover_restores(home: &Path, db: &mut Database) -> Result<()> {
|
||||
let base = home.join(".local/share/lepton/contexts");
|
||||
let base = contexts_root(home);
|
||||
let legacy = home.join(".local/share/lepton/contexts");
|
||||
let mut locations = BTreeMap::new();
|
||||
for id in &db.owned_contexts {
|
||||
ensure!(safe(id), "Invalid managed context");
|
||||
locations.insert(id.clone(), base.join(id));
|
||||
}
|
||||
if let Ok(entries) = fs::read_dir(&base) {
|
||||
for e in entries.flatten() {
|
||||
let id = e.file_name().to_string_lossy().into_owned();
|
||||
if safe(&id) && !id.starts_with("steamlaunch-") && e.file_type()?.is_dir() {
|
||||
locations.insert(id, e.path());
|
||||
for directory in [&base, &legacy] {
|
||||
if let Ok(entries) = fs::read_dir(directory) {
|
||||
for e in entries.flatten() {
|
||||
let id = e.file_name().to_string_lossy().into_owned();
|
||||
if safe(&id) && !id.starts_with("steamlaunch-") && e.file_type()?.is_dir()
|
||||
&& (directory == &base || !db.owned_contexts.contains(&id)) {
|
||||
locations.insert(id, e.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2702,8 +3021,7 @@ fn operate_internal(
|
||||
let c = existing.unwrap_or_else(|| Container {
|
||||
id: context.clone(),
|
||||
name: context.clone(),
|
||||
baked: home
|
||||
.join(".local/share/lepton/contexts")
|
||||
baked: contexts_root(home)
|
||||
.join(&context)
|
||||
.join("baked"),
|
||||
running: false,
|
||||
@@ -3865,6 +4183,73 @@ setup_podman_mounts
|
||||
assert_eq!(fs::read(&checkpoint).unwrap(), b"offset=19");
|
||||
}
|
||||
#[test]
|
||||
fn direct_boot_keeps_real_android_host_folders() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let library = dir.path().join("lepton/liblepton");
|
||||
fs::create_dir_all(&library).unwrap();
|
||||
fs::write(library.join("liblepton.sh"), r#"
|
||||
function data_mount_path() { echo "$TEST_DATA"; }
|
||||
function setup_mounts() {
|
||||
rm -rf "$(data_mount_path)/media/0"
|
||||
mkdir -p "$(data_mount_path)/media"
|
||||
local TARGET_PATH="$TEST_EXTERNAL"
|
||||
mkdir -p "$TARGET_PATH"
|
||||
if [[ -d "${HOME}/Documents" ]]; then
|
||||
rm -rf "${TARGET_PATH}/Documents"
|
||||
ln -s "${HOME}/Documents" "${TARGET_PATH}/Documents"
|
||||
fi
|
||||
if [[ -d "${HOME}/Downloads" ]]; then
|
||||
rm -rf "${TARGET_PATH}/Download"
|
||||
ln -s "${HOME}/Downloads" "${TARGET_PATH}/Download"
|
||||
fi
|
||||
if [[ -d "${HOME}/Videos" ]]; then
|
||||
rm -rf "${TARGET_PATH}/Movies"
|
||||
ln -s "${HOME}/Videos" "${TARGET_PATH}/Movies"
|
||||
fi
|
||||
}
|
||||
function setup_podman_mounts() { setup_mounts; }
|
||||
"#).unwrap();
|
||||
let source = r#"#!/bin/bash
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
source "${SCRIPT_DIR}/liblepton/liblepton.sh"
|
||||
setup_podman_mounts
|
||||
"#;
|
||||
let entry = dir.path().join("entry.sh");
|
||||
fs::write(&entry, direct_launch_script(source).unwrap()).unwrap();
|
||||
let home = dir.path().join("home");
|
||||
let external = dir.path().join("external");
|
||||
let data = dir.path().join("data");
|
||||
for folder in ["Documents", "Downloads", "Videos"] {
|
||||
fs::create_dir_all(home.join(folder)).unwrap();
|
||||
}
|
||||
let run = || Command::new("bash")
|
||||
.arg(&entry)
|
||||
.env("FRAMELY_LEPTON_DIR", library.parent().unwrap())
|
||||
.env("HOME", &home)
|
||||
.env("TEST_DATA", &data)
|
||||
.env("TEST_EXTERNAL", &external)
|
||||
.output().unwrap();
|
||||
let first = run();
|
||||
assert!(first.status.success(), "{}", String::from_utf8_lossy(&first.stderr));
|
||||
for (target, source) in [("Documents", "Documents"), ("Download", "Downloads"), ("Movies", "Videos")] {
|
||||
assert_eq!(fs::read_link(external.join(target)).unwrap(), home.join(source));
|
||||
}
|
||||
fs::remove_file(external.join("Documents")).unwrap();
|
||||
fs::create_dir(external.join("Documents")).unwrap();
|
||||
fs::write(external.join("Documents/keep.txt"), b"android file").unwrap();
|
||||
let second = run();
|
||||
assert!(second.status.success(), "{}", String::from_utf8_lossy(&second.stderr));
|
||||
assert_eq!(fs::read(external.join("Documents/keep.txt")).unwrap(), b"android file");
|
||||
assert!(external.join("Documents").is_dir());
|
||||
fs::remove_file(external.join("Download")).unwrap();
|
||||
std::os::unix::fs::symlink(dir.path().join("unexpected"), external.join("Download")).unwrap();
|
||||
let third = run();
|
||||
assert!(!third.status.success());
|
||||
assert!(external.join("Download").is_symlink());
|
||||
assert_eq!(fs::read(external.join("Documents/keep.txt")).unwrap(), b"android file");
|
||||
}
|
||||
#[test]
|
||||
fn direct_boot_binds_resources_and_reuses_shader_cache() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let library = dir.path().join("lepton/liblepton");
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ pub(super) fn delete(home: &Path, db: &mut Database, c: &Container, log: &Path)
|
||||
} else {
|
||||
c.id.clone()
|
||||
};
|
||||
let standard = home.join(".local/share/lepton/contexts").join(&c.id);
|
||||
let standard = (if db.owned_contexts.contains(&c.id) { super::contexts_root(home) } else { home.join(".local/share/lepton/contexts") }).join(&c.id);
|
||||
let default_location = !c.id.starts_with("external-")
|
||||
&& !fs::symlink_metadata(&standard).is_ok_and(|m| m.file_type().is_symlink())
|
||||
&& fs::canonicalize(&standard).is_ok_and(|p| p == standard && p.join("baked") == c.baked);
|
||||
|
||||
@@ -214,14 +214,15 @@ fn candidates(home: &Path, db: &Database) -> Vec<Container> {
|
||||
.filter(|r| !r.removed)
|
||||
.map(|r| r.context.clone())
|
||||
.collect();
|
||||
let base = home.join(".local/share/lepton/contexts");
|
||||
let base = super::contexts_root(home);
|
||||
let default_base = home.join(".local/share/lepton/contexts");
|
||||
let mut out = Vec::new();
|
||||
for id in contexts {
|
||||
if !safe(&id) || id.starts_with("steamlaunch-") {
|
||||
continue;
|
||||
}
|
||||
let mut name = id.clone();
|
||||
let mut baked = base.join(&id).join("baked");
|
||||
let mut baked = (if db.owned_contexts.contains(&id) { &base } else { &default_base }).join(&id).join("baked");
|
||||
if id.starts_with("external-") {
|
||||
let Some(path) = db
|
||||
.roots
|
||||
|
||||
+26
-3
@@ -30,9 +30,17 @@ if [[ -n "${FRAMELY_NATIVE_APK_DIR:-}" ]]; then
|
||||
framely_native_mounts="$(declare -f setup_mounts)"
|
||||
framely_native_lower='APP_APK_DIR="$(dirname "$(get_apk_path)")"'
|
||||
framely_native_upper='$(get_baked_app_data_dir)/app_overlay'
|
||||
[[ "$framely_native_mounts" == *"$framely_native_lower"* && "$framely_native_mounts" == *"$framely_native_upper"* ]] || { echo 'Unsupported native APK mounts; keeping application data unchanged' >&2; exit 64; }
|
||||
[[ "$framely_native_mounts" == *"$framely_native_lower"* ]] || { echo 'Unsupported native APK mounts; keeping application data unchanged' >&2; exit 64; }
|
||||
if [[ "$framely_native_mounts" == *"$framely_native_upper"* ]]; then
|
||||
framely_native_mounts="${framely_native_mounts//"$framely_native_upper"/'${FRAMELY_NATIVE_OVERLAY:?}/upper'}"
|
||||
elif [[ "$framely_native_mounts" == *'APP_OVERLAY_UPPERDIR="$(app_datadir)"'* ]] && declare -F app_datadir >/dev/null && [[ "$(declare -f app_datadir)" == *'$(get_baked_app_data_dir $@)/app_overlay'* ]]; then
|
||||
# Lepton 3 routes the APK upper directory through app_datadir.
|
||||
function app_datadir() { print "${FRAMELY_NATIVE_OVERLAY:?}/upper"; }
|
||||
else
|
||||
echo 'Unsupported native APK mounts; keeping application data unchanged' >&2
|
||||
exit 64
|
||||
fi
|
||||
framely_native_mounts="${framely_native_mounts//"$framely_native_lower"/'APP_APK_DIR="${FRAMELY_NATIVE_APK_DIR:?}"'}"
|
||||
framely_native_mounts="${framely_native_mounts//"$framely_native_upper"/'${FRAMELY_NATIVE_OVERLAY:?}/upper'}"
|
||||
function app_workdir() { print "${FRAMELY_NATIVE_OVERLAY:?}/work"; }
|
||||
eval "$framely_native_mounts"
|
||||
unset framely_native_mounts framely_native_lower framely_native_upper
|
||||
@@ -344,6 +352,10 @@ function setup_mounts() {{
|
||||
setup_mounts
|
||||
"#
|
||||
);
|
||||
for source in [source.clone(), source.replace(
|
||||
" local APP_OVERLAY_UPPERDIR=\"$(get_baked_app_data_dir)/app_overlay\"",
|
||||
" local APP_OVERLAY_UPPERDIR=\"$(app_datadir)\"",
|
||||
).replacen("function setup_mounts()", "function app_datadir() { print \"$(get_baked_app_data_dir $@)/app_overlay\"; }\nfunction setup_mounts()", 1)] {
|
||||
for package in ["one", "two"] {
|
||||
let out = Command::new("bash")
|
||||
.args(["-c", &source])
|
||||
@@ -362,6 +374,7 @@ setup_mounts
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), format!("/baked/data_overlay/app/{package}|/baked/native-{package}/upper|/baked/native-{package}/work"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn native_hooks_keep_storage_and_selected_activity_after_early_exit() {
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
@@ -382,6 +395,11 @@ function setup_podman_mounts() { setup_mounts; }
|
||||
function podman_mount_entry() { :; }
|
||||
function print() { printf '%s\n' "$*"; }
|
||||
function println() { print "$@"; }
|
||||
function prepare_baked_data_for_removal() { rm -rf "$TEST_DATA"; }
|
||||
function remove_prefix() {
|
||||
prepare_baked_data_for_removal
|
||||
rm -rf "$LEPTON_PREFIX"
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -390,7 +408,7 @@ set -euo pipefail
|
||||
COMMAND=waitforexitandrun
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
source "${SCRIPT_DIR}/liblepton/liblepton.sh"
|
||||
function teardown() { clear_baked_app_data 'early exit'; }
|
||||
function teardown() { clear_baked_app_data 'early exit'; remove_prefix; }
|
||||
case "${COMMAND}" in
|
||||
waitforexitandrun)
|
||||
is_app_baked
|
||||
@@ -413,6 +431,9 @@ esac
|
||||
fs::create_dir_all(save.parent().unwrap()).unwrap();
|
||||
fs::write(&save, b"saved game").unwrap();
|
||||
for flat in [true, false] {
|
||||
let prefix = t.path().join("lepton-prefix");
|
||||
fs::create_dir_all(&prefix).unwrap();
|
||||
fs::write(prefix.join("temporary"), b"runtime").unwrap();
|
||||
let out = Command::new("bash")
|
||||
.arg(&entry)
|
||||
.env("FRAMELY_LEPTON_DIR", lib.parent().unwrap())
|
||||
@@ -424,6 +445,7 @@ esac
|
||||
.env("FRAMELY_NATIVE_ACTIVITY", "test.Selected")
|
||||
.env("APP_WANTS_FLATSCREEN", if flat { "true" } else { "false" })
|
||||
.env("TEST_DATA", &data)
|
||||
.env("LEPTON_PREFIX", &prefix)
|
||||
.env("TEST_PROPS", t.path().join("props"))
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -441,6 +463,7 @@ esac
|
||||
assert!(String::from_utf8_lossy(&out.stdout).contains(expected));
|
||||
assert_eq!(fs::read(&save).unwrap(), b"saved game");
|
||||
assert_eq!(fs::read(&download).unwrap(), b"resume-me");
|
||||
assert!(!prefix.exists(), "Temporary prefix must still be cleaned up");
|
||||
}
|
||||
assert!(script("unknown future native entry").is_err());
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@ impl Default for Jobs {
|
||||
}
|
||||
}
|
||||
impl Jobs {
|
||||
pub fn active_kind(&self, kind: &str) -> bool {
|
||||
self.state.lock().unwrap().jobs.values().any(|job| job.active && job.public["kind"] == kind)
|
||||
}
|
||||
fn expire(state: &mut State) {
|
||||
for job in state.jobs.values_mut() {
|
||||
if !job.active
|
||||
|
||||
@@ -21,6 +21,7 @@ mod relations;
|
||||
mod service;
|
||||
mod session;
|
||||
mod steam;
|
||||
mod storage_location;
|
||||
mod subscriptions;
|
||||
mod terminal;
|
||||
#[cfg(test)]
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
use anyhow::{bail, ensure, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Component, Path};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const API_VERSION: u32 = 1;
|
||||
|
||||
@@ -814,6 +814,8 @@ pub struct AgreementAcceptance {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Database {
|
||||
#[serde(default)]
|
||||
pub plugin_storage: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
pub casting: crate::cast::settings::Settings,
|
||||
#[serde(default = "default_language")]
|
||||
|
||||
+6
-6
@@ -133,11 +133,9 @@ pub fn open_web_link(url: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command(m: &Manifest, payload: &Path, manager: u32, logs: &Path, unit: &str) -> Result<Command> {
|
||||
fn command(m: &Manifest, payload: &Path, manager: u32, _logs: &Path, storage: &Path, unit: &str) -> Result<Command> {
|
||||
let b = m.backend.as_ref().context("Plugin has no backend")?;
|
||||
let home = logs
|
||||
.parent()
|
||||
.context("Missing state directory")?
|
||||
let home = storage
|
||||
.join("data")
|
||||
.join(&m.id)
|
||||
.join(match b.run_as {
|
||||
@@ -216,6 +214,7 @@ pub fn hook(
|
||||
payload: &Path,
|
||||
manager: u32,
|
||||
logs: &Path,
|
||||
storage: &Path,
|
||||
context: Value,
|
||||
timeout: Duration,
|
||||
) -> Result<()> {
|
||||
@@ -231,7 +230,7 @@ pub fn hook(
|
||||
ui_visibility_events: false,
|
||||
});
|
||||
let unit = format!("framely-hook-{}-{:032x}", m.id, rand::random::<u128>());
|
||||
let cmd = command(&launch, payload, manager, logs, &unit)?;
|
||||
let cmd = command(&launch, payload, manager, logs, storage, &unit)?;
|
||||
// Insert env/property arguments before the executable delimiter.
|
||||
let args: Vec<_> = cmd.get_args().map(|a| a.to_owned()).collect();
|
||||
let split = args
|
||||
@@ -295,9 +294,10 @@ impl Running {
|
||||
manager: u32,
|
||||
events: Events,
|
||||
logs: &Path,
|
||||
storage: &Path,
|
||||
) -> Result<Self> {
|
||||
let unit = format!("framely-backend-{}", m.id);
|
||||
let mut cmd = command(m, payload, manager, logs, &unit)?;
|
||||
let mut cmd = command(m, payload, manager, logs, storage, &unit)?;
|
||||
fs::create_dir_all(logs)?;
|
||||
cmd.stderr(Stdio::piped());
|
||||
let mut child = cmd.spawn().context("Cannot start plugin service")?;
|
||||
|
||||
+296
-30
@@ -43,11 +43,22 @@ pub struct Service {
|
||||
runtime: BTreeMap<String, Value>,
|
||||
waiting: BTreeSet<String>,
|
||||
batching: bool,
|
||||
storage_jobs: crate::jobs::Jobs,
|
||||
plugin_migration: bool,
|
||||
visibility: crate::visibility::Tracker,
|
||||
visibility_pending: BTreeSet<String>,
|
||||
launch_contexts: BTreeMap<String, Value>,
|
||||
}
|
||||
impl Service {
|
||||
fn plugin_store(&self) -> &Path {
|
||||
self.db.plugin_storage.as_deref().unwrap_or(&self.root)
|
||||
}
|
||||
fn ensure_plugin_store(&self) -> Result<()> {
|
||||
let path = self.plugin_store();
|
||||
let meta = if self.db.plugin_storage.is_some() { fs::symlink_metadata(path)? } else { fs::metadata(path)? };
|
||||
ensure!(meta.is_dir() && (self.db.plugin_storage.is_none() || !meta.file_type().is_symlink()), "插件存储目录不可用:{}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
pub fn prepare_uninstall(&mut self) -> Result<Value> {
|
||||
self.cancel_automatic_check();
|
||||
self.updater.ensure_idle()?;
|
||||
@@ -78,7 +89,7 @@ impl Service {
|
||||
Value::Null,
|
||||
)
|
||||
.with_context(|| format!("插件 {id} 卸载失败,本体保留,请重试"))?;
|
||||
fs::remove_dir_all(self.root.join("plugins").join(id))?;
|
||||
fs::remove_dir_all(self.plugin_store().join("plugins").join(id))?;
|
||||
self.db.plugins.remove(id);
|
||||
self.db.notification_settings.plugins.remove(id);
|
||||
self.runtime.remove(id);
|
||||
@@ -99,6 +110,13 @@ impl Service {
|
||||
} else {
|
||||
Database::default()
|
||||
};
|
||||
let migration = root.join("plugin-storage-migration.json");
|
||||
if let Ok(bytes) = fs::read(&migration) {
|
||||
let record: Value = serde_json::from_slice(&bytes)?;
|
||||
if record["target"].as_str() == db.plugin_storage.as_deref().and_then(Path::to_str) {
|
||||
fs::remove_file(migration)?;
|
||||
}
|
||||
}
|
||||
db.update_check.validate()?;
|
||||
db.launcher.validate()?;
|
||||
db.casting.validate()?;
|
||||
@@ -124,8 +142,9 @@ impl Service {
|
||||
if let Ok(bytes) = fs::read(root.join("install-transaction.json")) {
|
||||
let journal: Value = serde_json::from_slice(&bytes)?;
|
||||
let original: Database = serde_json::from_value(journal["database"].clone())?;
|
||||
restore_replaced(root, &journal)?;
|
||||
restore_links(
|
||||
root,
|
||||
original.plugin_storage.as_deref().unwrap_or(root),
|
||||
&original,
|
||||
&serde_json::from_value::<Vec<String>>(journal["targets"].clone())?,
|
||||
)?;
|
||||
@@ -144,7 +163,7 @@ impl Service {
|
||||
crate::subscriptions::initialize(&mut db)?;
|
||||
// Rehydrate presentation after a state-schema downgrade from immutable payloads.
|
||||
for installed in db.plugins.values_mut() {
|
||||
let path = root
|
||||
let path = db.plugin_storage.as_deref().unwrap_or(root)
|
||||
.join("plugins")
|
||||
.join(&installed.manifest.id)
|
||||
.join("versions")
|
||||
@@ -203,6 +222,8 @@ impl Service {
|
||||
runtime: BTreeMap::new(),
|
||||
waiting: resume,
|
||||
batching: false,
|
||||
storage_jobs: Default::default(),
|
||||
plugin_migration: false,
|
||||
visibility: Default::default(),
|
||||
visibility_pending: BTreeSet::new(),
|
||||
launch_contexts: BTreeMap::new(),
|
||||
@@ -240,7 +261,7 @@ impl Service {
|
||||
fn payload(&self, id: &str) -> Result<PathBuf> {
|
||||
let p = self.plugin(id)?;
|
||||
Ok(self
|
||||
.root
|
||||
.plugin_store()
|
||||
.join("plugins")
|
||||
.join(id)
|
||||
.join("versions")
|
||||
@@ -288,7 +309,7 @@ impl Service {
|
||||
previous: Option<&str>,
|
||||
extra: Value,
|
||||
) -> Value {
|
||||
json!({"pluginId":m.id,"phase":phase,"reason":reason,"version":m.version,"previousVersion":previous,"dataDir":self.root.join("data").join(&m.id).join(match m.run_as(){RunAs::Steamos=>"steamos",RunAs::Root=>"root"}),"exit":extra,"launchContext":self.launch_contexts.get(&format!("{}/quick",m.id))})
|
||||
json!({"pluginId":m.id,"phase":phase,"reason":reason,"version":m.version,"previousVersion":previous,"dataDir":self.plugin_store().join("data").join(&m.id).join(match m.run_as(){RunAs::Steamos=>"steamos",RunAs::Root=>"root"}),"exit":extra,"launchContext":self.launch_contexts.get(&format!("{}/quick",m.id))})
|
||||
}
|
||||
fn command_hook(
|
||||
&mut self,
|
||||
@@ -315,6 +336,7 @@ impl Service {
|
||||
payload,
|
||||
self.manager,
|
||||
&self.root.join("logs"),
|
||||
self.plugin_store(),
|
||||
self.context(m, phase, reason, previous, extra),
|
||||
Duration::from_secs(l.timeout_seconds),
|
||||
);
|
||||
@@ -376,6 +398,7 @@ impl Service {
|
||||
self.manager,
|
||||
self.events.clone(),
|
||||
&self.root.join("logs"),
|
||||
self.plugin_store(),
|
||||
)?;
|
||||
if let Some(l) = m.lifecycle.as_ref().filter(|l| l.on_start) {
|
||||
running.call_timeout(
|
||||
@@ -634,6 +657,7 @@ impl Service {
|
||||
}
|
||||
}
|
||||
pub fn maintenance(&mut self) {
|
||||
if self.plugin_migration { return; }
|
||||
self.expire_notifications();
|
||||
if self.visibility.expire(Instant::now()) {
|
||||
self.publish_visibility();
|
||||
@@ -736,6 +760,9 @@ impl Service {
|
||||
.is_some_and(|a| a.version == AGREEMENT_VERSION && a.accepted_at > 0)
|
||||
}
|
||||
pub fn handle(&mut self, method: &str, p: Value) -> Result<Value> {
|
||||
if self.plugin_migration && (method.starts_with("plugin.") || matches!(method, "install" | "install.batch" | "safeMode" | "system.uninstall.prepare")) {
|
||||
bail!("插件存储正在迁移,请等待完成");
|
||||
}
|
||||
ensure!(
|
||||
self.agreement_accepted()
|
||||
|| matches!(
|
||||
@@ -766,6 +793,18 @@ impl Service {
|
||||
}
|
||||
let id = p["plugin"].as_str().unwrap_or("").to_owned();
|
||||
match method {
|
||||
"storage.plugin.status" => {
|
||||
let store = self.plugin_store();
|
||||
let (program_bytes, program_incomplete) = crate::storage_location::disk_bytes(&store.join("plugins"))?;
|
||||
let (data_bytes, data_incomplete) = crate::storage_location::disk_bytes(&store.join("data"))?;
|
||||
let details = self.db.plugins.iter().map(|(id, installed)| {
|
||||
let (program, a) = crate::storage_location::disk_bytes(&store.join("plugins").join(id))?;
|
||||
let (data, b) = crate::storage_location::disk_bytes(&store.join("data").join(id))?;
|
||||
Ok(json!({"id":id,"name":installed.manifest.name,"bytes":program.saturating_add(data),"programBytes":program,"dataBytes":data,"sizeIncomplete":a || b}))
|
||||
}).collect::<Result<Vec<Value>>>()?;
|
||||
let device = fs::metadata(store).ok().map(|m| std::os::unix::fs::MetadataExt::dev(&m));
|
||||
Ok(json!({"path":store,"bytes":program_bytes.saturating_add(data_bytes),"sizeIncomplete":program_incomplete || data_incomplete,"device":device,"details":details,"available":self.ensure_plugin_store().is_ok(),"migrating":self.plugin_migration,"interrupted":fs::read(self.root.join("plugin-storage-migration.json")).ok().and_then(|b|serde_json::from_slice::<Value>(&b).ok()).map(|v|v["target"].clone())}))
|
||||
},
|
||||
"host.ui.visibility" => {
|
||||
let views = serde_json::from_value(p)?;
|
||||
if self.visibility.report(views, Instant::now())? {
|
||||
@@ -1007,9 +1046,9 @@ impl Service {
|
||||
self.db.notification_settings.plugins.remove(&id);
|
||||
self.notifications.retain(|_, n| n.plugin != id);
|
||||
self.save_notifications()?;
|
||||
fs::remove_dir_all(self.root.join("plugins").join(&id))?;
|
||||
fs::remove_dir_all(self.plugin_store().join("plugins").join(&id))?;
|
||||
if p["purge"].as_bool() == Some(true) {
|
||||
let data = self.root.join("data").join(&id);
|
||||
let data = self.plugin_store().join("data").join(&id);
|
||||
if data.exists() {
|
||||
fs::remove_dir_all(data)?;
|
||||
}
|
||||
@@ -2024,35 +2063,41 @@ impl Service {
|
||||
"Unknown installation source"
|
||||
);
|
||||
}
|
||||
let base = self.root.join("plugins").join(&id);
|
||||
let base = self.plugin_store().join("plugins").join(&id);
|
||||
self.ensure_plugin_store()?;
|
||||
fs::create_dir_all(base.join("versions"))?;
|
||||
let dest = base.join("versions").join(&v.manifest.version);
|
||||
let newly_created = !dest.exists();
|
||||
if newly_created {
|
||||
let stage = base.join(format!(".stage-{}", rand::random::<u64>()));
|
||||
if let Err(e) = package::unpack(&v, &stage) {
|
||||
let _ = fs::remove_dir_all(&stage);
|
||||
return Err(e);
|
||||
}
|
||||
fs::rename(stage, &dest)?;
|
||||
} else {
|
||||
let cached: Manifest = serde_json::from_slice(&fs::read(dest.join("manifest.json"))?)?;
|
||||
ensure!(
|
||||
serde_json::to_value(&cached)? == serde_json::to_value(&v.manifest)?,
|
||||
"同一插件版本的发布内容发生变化,拒绝替换"
|
||||
);
|
||||
for (file, expected) in &v.manifest.files {
|
||||
let path = dest.join(file);
|
||||
ensure!(
|
||||
fs::symlink_metadata(&path)?.file_type().is_file()
|
||||
&& package::digest(&fs::read(path)?) == *expected,
|
||||
"本机版本文件校验失败:{file}"
|
||||
);
|
||||
}
|
||||
let stage = base.join(format!(".stage-{}", rand::random::<u64>()));
|
||||
if let Err(e) = package::unpack(&v, &stage) {
|
||||
let _ = fs::remove_dir_all(&stage);
|
||||
return Err(e);
|
||||
}
|
||||
let was_running = self.running.contains_key(&id);
|
||||
let old = self.db.plugins.get(&id).cloned();
|
||||
if !newly_created {
|
||||
if let Err(e) = record_replacement(&self.root, &id, &v.manifest.version, &self.db, was_running, self.batching) {
|
||||
let _ = fs::remove_dir_all(&stage);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
self.stop_reason(&id, if old.is_some() { "update" } else { "install" });
|
||||
let switch = if !newly_created {
|
||||
let journal: Value = serde_json::from_slice(&fs::read(self.root.join("install-transaction.json"))?)?;
|
||||
let backup = replacement_path(&self.root, &journal, &id)?;
|
||||
fs::rename(&dest, &backup).and_then(|_| fs::rename(&stage, &dest))
|
||||
} else {
|
||||
fs::rename(&stage, &dest)
|
||||
};
|
||||
if let Err(error) = switch {
|
||||
let _ = fs::remove_dir_all(&stage);
|
||||
if !newly_created {
|
||||
restore_one_replacement(&self.root, &id)?;
|
||||
if !self.batching { clear_replacement_journal(&self.root)?; }
|
||||
}
|
||||
if !self.batching && was_running { let _ = self.start_reason(&id, "install-rollback"); }
|
||||
return Err(error.into());
|
||||
}
|
||||
let phase = if old.is_some() {
|
||||
"onUpdate"
|
||||
} else {
|
||||
@@ -2066,6 +2111,10 @@ impl Service {
|
||||
old.as_ref().map(|p| p.manifest.version.as_str()),
|
||||
Value::Null,
|
||||
) {
|
||||
if !newly_created {
|
||||
restore_one_replacement(&self.root, &id)?;
|
||||
if !self.batching { clear_replacement_journal(&self.root)?; }
|
||||
}
|
||||
if newly_created {
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
}
|
||||
@@ -2100,6 +2149,10 @@ impl Service {
|
||||
fs::rename(link, base.join("current"))?;
|
||||
self.db.plugins.insert(id.clone(), installed);
|
||||
if let Err(e) = self.save() {
|
||||
if !newly_created {
|
||||
restore_one_replacement(&self.root, &id)?;
|
||||
if !self.batching { clear_replacement_journal(&self.root)?; }
|
||||
}
|
||||
if let Some(old) = old.clone() {
|
||||
let _ = fs::remove_file(base.join("current"));
|
||||
let _ = symlink(
|
||||
@@ -2125,6 +2178,10 @@ impl Service {
|
||||
if let Err(error) = self.start_reason(&id, "install") {
|
||||
// A startup hook failure is an installation failure, not a successful activation.
|
||||
self.stop_reason(&id, "install-rollback");
|
||||
if !newly_created {
|
||||
restore_one_replacement(&self.root, &id)?;
|
||||
clear_replacement_journal(&self.root)?;
|
||||
}
|
||||
if let Some(previous) = old {
|
||||
let _ = fs::remove_file(base.join("current"));
|
||||
let _ = symlink(
|
||||
@@ -2146,6 +2203,9 @@ impl Service {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
if !self.batching && !newly_created {
|
||||
clear_replacement_journal(&self.root)?;
|
||||
}
|
||||
Ok(json!(self.db.plugins[&id]))
|
||||
}
|
||||
fn disable_dependents(&mut self, id: &str, p: &Value) -> Result<()> {
|
||||
@@ -2361,7 +2421,9 @@ impl Service {
|
||||
self.stop_reason(id, "transaction-rollback");
|
||||
self.waiting.remove(id);
|
||||
}
|
||||
restore_links(&self.root, &original, &targets)?;
|
||||
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
restore_replaced(&self.root, &journal)?;
|
||||
restore_links(original.plugin_storage.as_deref().unwrap_or(&self.root), &original, &targets)?;
|
||||
self.db = original;
|
||||
self.save()?;
|
||||
for id in &running {
|
||||
@@ -2371,7 +2433,9 @@ impl Service {
|
||||
fs::remove_file(path)?;
|
||||
return Err(error).context("批量安装失败,已恢复原版本和启用状态");
|
||||
}
|
||||
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
fs::remove_file(path)?;
|
||||
discard_replacements(&self.root, &journal)?;
|
||||
fs::File::open(&self.root)?.sync_all()?;
|
||||
for id in impacted {
|
||||
process::event(
|
||||
@@ -2402,6 +2466,70 @@ fn restore_links(root: &Path, original: &Database, targets: &[String]) -> Result
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn replacement_path(root: &Path, journal: &Value, id: &str) -> Result<PathBuf> {
|
||||
valid_id(id)?;
|
||||
let name = journal["backups"][id]
|
||||
.as_str()
|
||||
.context("缺少同版本安装备份记录")?;
|
||||
ensure!(name.starts_with(".previous-") && !name.contains('/') && !name.contains('\\'), "无效插件备份路径");
|
||||
let original: Database = serde_json::from_value(journal["database"].clone())?;
|
||||
Ok(original.plugin_storage.as_deref().unwrap_or(root).join("plugins").join(id).join(name))
|
||||
}
|
||||
fn record_replacement(root: &Path, id: &str, version: &str, original: &Database, running: bool, batching: bool) -> Result<()> {
|
||||
let path = root.join("install-transaction.json");
|
||||
let mut journal: Value = if batching {
|
||||
serde_json::from_slice(&fs::read(&path)?)?
|
||||
} else {
|
||||
json!({"database": original, "targets": [id], "running": if running {vec![id]} else {vec![]}})
|
||||
};
|
||||
let name = format!(".previous-{}-{:016x}", version, rand::random::<u64>());
|
||||
ensure!(!name.contains('/') && !name.contains('\\'), "无效插件版本");
|
||||
journal["backups"][id] = json!(name);
|
||||
journal["replacementVersions"][id] = json!(version);
|
||||
let temporary = root.join("install-transaction.tmp");
|
||||
fs::write(&temporary, serde_json::to_vec(&journal)?)?;
|
||||
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
|
||||
fs::File::open(&temporary)?.sync_all()?;
|
||||
fs::rename(temporary, &path)?;
|
||||
fs::File::open(root)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
fn restore_one_replacement(root: &Path, id: &str) -> Result<()> {
|
||||
let journal: Value = serde_json::from_slice(&fs::read(root.join("install-transaction.json"))?)?;
|
||||
let backup = replacement_path(root, &journal, id)?;
|
||||
if backup.is_dir() {
|
||||
let version = journal["replacementVersions"][id].as_str().context("缺少替换版本")?;
|
||||
let original: Database = serde_json::from_value(journal["database"].clone())?;
|
||||
let dest = original.plugin_storage.as_deref().unwrap_or(root).join("plugins").join(id).join("versions").join(version);
|
||||
if dest.exists() { fs::remove_dir_all(&dest)?; }
|
||||
fs::rename(backup, dest)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn restore_replaced(root: &Path, journal: &Value) -> Result<()> {
|
||||
if let Some(backups) = journal["backups"].as_object() {
|
||||
for id in backups.keys() {
|
||||
restore_one_replacement(root, id)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn discard_replacements(root: &Path, journal: &Value) -> Result<()> {
|
||||
if let Some(backups) = journal["backups"].as_object() {
|
||||
for id in backups.keys() {
|
||||
let path = replacement_path(root, journal, id)?;
|
||||
if path.exists() { fs::remove_dir_all(path)?; }
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn clear_replacement_journal(root: &Path) -> Result<()> {
|
||||
let path = root.join("install-transaction.json");
|
||||
let journal: Value = serde_json::from_slice(&fs::read(&path)?)?;
|
||||
fs::remove_file(path)?;
|
||||
fs::File::open(root)?.sync_all()?;
|
||||
discard_replacements(root, &journal)
|
||||
}
|
||||
fn plugin_restarts(db: &Database, id: &str) -> bool {
|
||||
db.plugins.get(id).is_some_and(|p| {
|
||||
p.enabled
|
||||
@@ -2423,6 +2551,105 @@ pub fn now_ms() -> u64 {
|
||||
.as_millis() as u64
|
||||
}
|
||||
fn dispatch(core: &Arc<Mutex<Service>>, method: &str, params: Value) -> Result<Value> {
|
||||
if method == "storage.plugin.job" {
|
||||
let service = core.lock().unwrap();
|
||||
return service.storage_jobs.status(params["job"].as_str().context("缺少任务 ID")?);
|
||||
}
|
||||
if method == "storage.plugin.start" {
|
||||
let path = PathBuf::from(params["path"].as_str().context("缺少目标路径")?);
|
||||
let (jobs, source, target, root, resume, follow_links) = {
|
||||
let mut service = core.lock().unwrap();
|
||||
ensure!(service.agreement_accepted(), "请先同意用户协议和隐私声明");
|
||||
ensure!(!service.plugin_migration, "插件存储正在迁移");
|
||||
ensure!(!service.root.join("install-transaction.json").exists(), "插件安装正在进行");
|
||||
let source = service.plugin_store().to_path_buf();
|
||||
service.ensure_plugin_store()?;
|
||||
let follow_links = service.db.plugin_storage.is_none();
|
||||
let root = service.root.clone();
|
||||
let journal = root.join("plugin-storage-migration.json");
|
||||
let (target, resume) = if journal.exists() {
|
||||
let record: Value = serde_json::from_slice(&fs::read(&journal)?)?;
|
||||
ensure!(record["source"] == json!(source), "存储迁移来源已改变,请检查原目录");
|
||||
if record["target"] == json!(path) {
|
||||
let resume = path.exists() && crate::storage_location::verified_selected(&source, &path, &["plugins".into(), "data".into()], follow_links)?;
|
||||
ensure!(!path.exists() || resume || (path.is_dir() && fs::read_dir(&path)?.next().is_none()), "上次迁移副本已与原目录不同,请选择新的空目录");
|
||||
(path.clone(), resume)
|
||||
} else { (crate::storage_location::validate_target(&source, &path, false)?, false) }
|
||||
} else {
|
||||
(crate::storage_location::validate_target(&source, &path, false)?, false)
|
||||
};
|
||||
service.plugin_migration = true;
|
||||
(service.storage_jobs.clone(), source, target, root, resume, follow_links)
|
||||
};
|
||||
let shared = core.clone();
|
||||
let task = jobs.task_progress("storage:plugin", move |cancel, progress| {
|
||||
let mut running = Vec::new();
|
||||
let result = (|| -> Result<Value> {
|
||||
crate::storage_location::write_journal(&root.join("plugin-storage-migration.json"), &json!({"source":source,"target":target}))?;
|
||||
progress(json!({"step":"stopping"}));
|
||||
{
|
||||
let mut service = shared.lock().unwrap();
|
||||
running = service.running.keys().cloned().collect();
|
||||
for id in running.iter().rev() { service.stop_reason(id, "storage-migration"); }
|
||||
service.recovery.clear();
|
||||
}
|
||||
cancel.check()?;
|
||||
if !resume { crate::storage_location::copy_selected(&source, &target, &["plugins".into(), "data".into()], follow_links, |step| progress(json!({"step":step})))?; }
|
||||
cancel.check()?;
|
||||
{
|
||||
let mut service = shared.lock().unwrap();
|
||||
let previous = service.db.plugin_storage.clone();
|
||||
service.db.plugin_storage = Some(target.clone());
|
||||
if let Err(error) = service.save() {
|
||||
service.db.plugin_storage = previous;
|
||||
return Err(error);
|
||||
}
|
||||
progress(json!({"step":"restarting"}));
|
||||
let restart = (|| -> Result<()> {
|
||||
for id in &running { service.start_reason(id, "storage-migration")?; }
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = restart {
|
||||
for id in &running { service.stop_reason(id, "storage-rollback"); }
|
||||
service.db.plugin_storage = previous;
|
||||
service.save()?;
|
||||
return Err(error).context("迁移后插件启动失败,已恢复原位置");
|
||||
}
|
||||
}
|
||||
fs::remove_file(root.join("plugin-storage-migration.json"))?;
|
||||
progress(json!({"step":"cleaning"}));
|
||||
let mut cleanup_errors = Vec::new();
|
||||
for name in ["plugins", "data"] {
|
||||
let old = source.join(name);
|
||||
if old.exists() {
|
||||
let remove = if follow_links && fs::symlink_metadata(&old).is_ok_and(|m|m.file_type().is_symlink()) {
|
||||
let physical = old.canonicalize();
|
||||
physical.and_then(|directory| {
|
||||
for entry in fs::read_dir(directory)? {
|
||||
let path = entry?.path();
|
||||
if path.is_dir() && !fs::symlink_metadata(&path)?.file_type().is_symlink() { fs::remove_dir_all(path)?; }
|
||||
else { fs::remove_file(path)?; }
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
} else { fs::remove_dir_all(old) };
|
||||
if let Err(error) = remove { cleanup_errors.push(error.to_string()); }
|
||||
}
|
||||
}
|
||||
Ok(json!({"path":target,"cleanupWarnings":cleanup_errors}))
|
||||
})();
|
||||
{
|
||||
let mut service = shared.lock().unwrap();
|
||||
if result.is_err() {
|
||||
for id in &running { let _ = service.start_reason(id, "storage-rollback"); }
|
||||
}
|
||||
service.plugin_migration = false;
|
||||
}
|
||||
result
|
||||
});
|
||||
if task.is_err() { core.lock().unwrap().plugin_migration = false; }
|
||||
return task;
|
||||
}
|
||||
if method == "network.password.verify" {
|
||||
let (root, verifier) = {
|
||||
let service = core.lock().unwrap();
|
||||
@@ -2578,6 +2805,45 @@ mod request_tests {
|
||||
assert_eq!(worker.join().unwrap(), json!(false));
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod storage_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn plugin_storage_migration_moves_files_and_survives_reload() {
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let state = temp.path().join("state");
|
||||
let mut service = crate::tests::accepted_service(&state, unsafe { libc::geteuid() }).unwrap();
|
||||
let package = crate::tests::fixture(temp.path(), "test.storage", "1.0.0", 1, None);
|
||||
service.handle("install", json!({"package":base64::Engine::encode(&base64::engine::general_purpose::STANDARD, package),"approve":true})).unwrap();
|
||||
let data = state.join("data/test.storage/steamos/save.txt");
|
||||
fs::create_dir_all(data.parent().unwrap()).unwrap();
|
||||
fs::write(&data, "saved").unwrap();
|
||||
let persistent = temp.path().join("persistent");
|
||||
fs::create_dir(&persistent).unwrap();
|
||||
for name in ["plugins", "data"] {
|
||||
fs::rename(state.join(name), persistent.join(name)).unwrap();
|
||||
symlink(persistent.join(name), state.join(name)).unwrap();
|
||||
}
|
||||
let target = temp.path().join("relocated");
|
||||
let core = Arc::new(Mutex::new(service));
|
||||
let job = dispatch(&core, "storage.plugin.start", json!({"path":target})).unwrap()["job"].as_str().unwrap().to_owned();
|
||||
let result = loop {
|
||||
let status = dispatch(&core, "storage.plugin.job", json!({"job":job})).unwrap();
|
||||
if status["phase"] == "done" || status["phase"] == "failed" { break status; }
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
assert_eq!(result["phase"], "done", "{result}");
|
||||
assert_eq!(fs::read_to_string(target.join("data/test.storage/steamos/save.txt")).unwrap(), "saved");
|
||||
assert!(target.join("plugins/test.storage/versions/1.0.0/manifest.json").is_file());
|
||||
assert!(fs::symlink_metadata(state.join("plugins")).unwrap().file_type().is_symlink());
|
||||
assert!(fs::read_dir(persistent.join("plugins")).unwrap().next().is_none());
|
||||
assert!(fs::read_dir(persistent.join("data")).unwrap().next().is_none());
|
||||
drop(core);
|
||||
let loaded = Service::load(&state, unsafe { libc::geteuid() }).unwrap();
|
||||
assert_eq!(loaded.db.plugin_storage.as_deref(), Some(target.as_path()));
|
||||
assert!(loaded.payload("test.storage").unwrap().is_dir());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod notification_tests {
|
||||
|
||||
+21
-4
@@ -11,6 +11,7 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
||||
static APK_STORAGE_API_GATE: Mutex<()> = Mutex::new(());
|
||||
|
||||
struct FrameAccess {
|
||||
plugin: String,
|
||||
@@ -180,10 +181,10 @@ impl Agent {
|
||||
plugin["manifest"]["files"].get(file).is_some(),
|
||||
"Unlisted asset"
|
||||
);
|
||||
let store = status["database"]["pluginStorage"].as_str().map(std::path::PathBuf::from).unwrap_or_else(|| self.state.clone());
|
||||
return send_file(
|
||||
r,
|
||||
&self
|
||||
.state
|
||||
&store
|
||||
.join("plugins")
|
||||
.join(id)
|
||||
.join("versions")
|
||||
@@ -410,8 +411,8 @@ impl Agent {
|
||||
ensure!(!p.is_null(), "Unknown plugin");
|
||||
let m: Manifest = serde_json::from_value(p["manifest"].clone())?;
|
||||
ensure!(m.files.contains_key(file), "Unlisted asset");
|
||||
let root = self
|
||||
.state
|
||||
let store = status["database"]["pluginStorage"].as_str().map(std::path::PathBuf::from).unwrap_or_else(|| self.state.clone());
|
||||
let root = store
|
||||
.join("plugins")
|
||||
.join(id)
|
||||
.join("versions")
|
||||
@@ -1035,6 +1036,8 @@ impl Agent {
|
||||
self.launcher_search.lock().unwrap().batch(&path, &names)
|
||||
}
|
||||
"apk.upload.start" => {
|
||||
let _gate = APK_STORAGE_API_GATE.lock().unwrap();
|
||||
ensure!(!self.jobs.active_kind("apk:storage"), "APK 存储正在迁移");
|
||||
let size = p["size"].as_u64().context("Missing APK upload size")?;
|
||||
let root = crate::apk::upload_dir(&crate::steam::home()?, size)?;
|
||||
self.jobs.uploads.start_in(size, &root)
|
||||
@@ -1070,6 +1073,17 @@ impl Agent {
|
||||
p["ticket"].as_str().context("Missing APK review")?,
|
||||
),
|
||||
"apk.cleanup.list" => crate::apk::cleanup_list(&crate::steam::home()?),
|
||||
"apk.storage.status" => crate::apk::storage_status(&crate::steam::home()?),
|
||||
"apk.storage.start" => {
|
||||
let _gate = APK_STORAGE_API_GATE.lock().unwrap();
|
||||
ensure!(!self.jobs.uploads.active() && !self.jobs.active_kind("apk:storage") && !self.jobs.active_kind("apk:operation"), "请等待 APK 上传或操作完成");
|
||||
let home = crate::steam::home()?;
|
||||
let kind = p["kind"].as_str().context("缺少存储类型")?.to_owned();
|
||||
let path = std::path::PathBuf::from(p["path"].as_str().context("缺少目标目录")?);
|
||||
self.jobs.task_progress("apk:storage", move |cancel, progress| {
|
||||
crate::apk::migrate_storage(&home, &kind, &path, cancel, progress)
|
||||
})
|
||||
}
|
||||
"apk.logs" => crate::apk::logs(
|
||||
&crate::steam::home()?,
|
||||
p["app"].as_str().context("Missing APK application")?,
|
||||
@@ -1437,6 +1451,9 @@ impl Agent {
|
||||
Ok(json!(true))
|
||||
}
|
||||
"status"
|
||||
| "storage.plugin.status"
|
||||
| "storage.plugin.start"
|
||||
| "storage.plugin.job"
|
||||
| "inspect"
|
||||
| "install"
|
||||
| "plugin.restart"
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, ffi::CString, fs, io::Read, os::unix::fs::{MetadataExt, FileTypeExt, symlink, PermissionsExt}, path::{Path, PathBuf}, process::Command};
|
||||
|
||||
/// Disk blocks used by a tree. The root may be an intentional storage link
|
||||
/// (the default plugin directories are links); nested links and mounts are skipped.
|
||||
pub fn disk_bytes(path: &Path) -> Result<(u64, bool)> {
|
||||
let root = match fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, false)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let path = if root.file_type().is_symlink() {
|
||||
match path.canonicalize() {
|
||||
Ok(path) => path,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, true)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
} else { path.to_path_buf() };
|
||||
let root = fs::symlink_metadata(&path)?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut total = 0u64;
|
||||
let mut incomplete = false;
|
||||
fn walk(path: &Path, dev: u64, seen: &mut std::collections::HashSet<(u64, u64)>, total: &mut u64, incomplete: &mut bool) -> Result<()> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if meta.dev() != dev || !seen.insert((meta.dev(), meta.ino())) { return Ok(()); }
|
||||
*total = total.saturating_add(meta.blocks().saturating_mul(512));
|
||||
if meta.is_dir() && !meta.file_type().is_symlink() {
|
||||
let entries = match fs::read_dir(path) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
for entry in entries {
|
||||
match entry {
|
||||
Ok(entry) => walk(&entry.path(), dev, seen, total, incomplete)?,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => *incomplete = true,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {},
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
walk(&path, root.dev(), &mut seen, &mut total, &mut incomplete)?;
|
||||
Ok((total, incomplete))
|
||||
}
|
||||
|
||||
pub fn volume(path: &Path) -> Result<Value> {
|
||||
let device = fs::metadata(path)?.dev();
|
||||
let path = std::ffi::CString::new(path.as_os_str().as_encoded_bytes())?;
|
||||
let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
|
||||
let result = unsafe { libc::statvfs(path.as_ptr(), stat.as_mut_ptr()) };
|
||||
if result != 0 { return Err(std::io::Error::last_os_error().into()); }
|
||||
let stat = unsafe { stat.assume_init() };
|
||||
let block = stat.f_frsize as u64;
|
||||
let total = (stat.f_blocks as u64).saturating_mul(block);
|
||||
let free = (stat.f_bfree as u64).saturating_mul(block);
|
||||
Ok(serde_json::json!({"total":total,"free":free,"device":device}))
|
||||
}
|
||||
|
||||
pub fn bytes(path: &Path) -> Result<u64> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let root = if meta.file_type().is_symlink() { path.canonicalize()? } else { path.to_path_buf() };
|
||||
let mut total = 0u64;
|
||||
fn walk(path: &Path, total: &mut u64) -> Result<()> {
|
||||
let m = fs::symlink_metadata(path)?;
|
||||
if m.is_file() { *total = total.saturating_add(m.len()); }
|
||||
if m.is_dir() && !m.file_type().is_symlink() {
|
||||
for entry in fs::read_dir(path)? { walk(&entry?.path(), total)?; }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
walk(&root, &mut total)?;
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// Legacy logical-size check retained for the migration permission test.
|
||||
#[cfg(test)]
|
||||
pub fn display_bytes(path: &Path) -> Result<(u64, bool)> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok((0, false)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let root = if meta.file_type().is_symlink() {
|
||||
match path.canonicalize() {
|
||||
Ok(root) => root,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return Ok((0, true)),
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
} else { path.to_path_buf() };
|
||||
fn walk(path: &Path, total: &mut u64, incomplete: &mut bool) -> Result<()> {
|
||||
let meta = match fs::symlink_metadata(path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if meta.is_file() { *total = total.saturating_add(meta.len()); }
|
||||
if meta.is_dir() && !meta.file_type().is_symlink() {
|
||||
let entries = match fs::read_dir(path) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { *incomplete = true; return Ok(()); }
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
for entry in entries { walk(&entry?.path(), total, incomplete)?; }
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let (mut total, mut incomplete) = (0, false);
|
||||
walk(&root, &mut total, &mut incomplete)?;
|
||||
Ok((total, incomplete))
|
||||
}
|
||||
|
||||
fn inventory(path: &Path) -> Result<BTreeMap<PathBuf, String>> {
|
||||
let mut files = BTreeMap::new();
|
||||
fn walk(root: &Path, rel: &Path, files: &mut BTreeMap<PathBuf, String>) -> Result<()> {
|
||||
let path = root.join(rel);
|
||||
let meta = fs::symlink_metadata(&path)?;
|
||||
let value = if meta.file_type().is_symlink() {
|
||||
format!("link:{}:{}:{}", fs::read_link(&path)?.display(), meta.uid(), meta.gid())
|
||||
} else if meta.is_dir() {
|
||||
for entry in fs::read_dir(&path)? { walk(root, &rel.join(entry?.file_name()), files)?; }
|
||||
if rel.as_os_str().is_empty() { "directory".to_owned() }
|
||||
else { format!("directory:{}:{}:{}", meta.uid(), meta.gid(), meta.mode() & 0o7777) }
|
||||
} else if meta.is_file() {
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hash = Sha256::new();
|
||||
let mut buffer = [0u8; 65536];
|
||||
loop { let n = file.read(&mut buffer)?; if n == 0 { break; } hash.update(&buffer[..n]); }
|
||||
format!("sha256:{}:{}:{}:{}", hex::encode(hash.finalize()), meta.uid(), meta.gid(), meta.mode() & 0o7777)
|
||||
} else if meta.file_type().is_char_device() && meta.rdev() == 0 {
|
||||
"overlay-whiteout".to_owned()
|
||||
} else { anyhow::bail!("存储中含有无法迁移的文件:{}", path.display()); };
|
||||
files.insert(rel.to_path_buf(), value);
|
||||
Ok(())
|
||||
}
|
||||
walk(path, Path::new(""), &mut files)?;
|
||||
Ok(files)
|
||||
}
|
||||
pub fn verified_equal(source: &Path, target: &Path) -> Result<bool> {
|
||||
if !source.is_dir() || !target.is_dir() { return Ok(false); }
|
||||
let _source_readable = ReadableDirs::prepare(source)?;
|
||||
let _target_readable = ReadableDirs::prepare(target)?;
|
||||
Ok(inventory(source)? == inventory(target)?)
|
||||
}
|
||||
fn selected_source(source: &Path, name: &str, follow_root_links: bool) -> Result<PathBuf> {
|
||||
let path = source.join(name);
|
||||
let meta = match fs::symlink_metadata(&path) {
|
||||
Ok(meta) => meta,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(path),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
ensure!(follow_root_links || !meta.file_type().is_symlink(), "存储目录不能是链接:{}", path.display());
|
||||
let resolved = if meta.file_type().is_symlink() { path.canonicalize()? } else { path };
|
||||
ensure!(resolved.is_dir(), "存储项目不是目录:{}", resolved.display());
|
||||
Ok(resolved)
|
||||
}
|
||||
pub fn verified_selected(source: &Path, target: &Path, names: &[String], follow_root_links: bool) -> Result<bool> {
|
||||
if !target.is_dir() { return Ok(false); }
|
||||
let mut expected = std::collections::BTreeSet::new();
|
||||
for name in names {
|
||||
if selected_source(source, name, follow_root_links)?.is_dir() { expected.insert(name.clone()); }
|
||||
}
|
||||
let actual: std::collections::BTreeSet<String> = fs::read_dir(target)?.map(|entry| entry.map(|e|e.file_name().to_string_lossy().to_string())).collect::<std::io::Result<_>>()?;
|
||||
if actual != expected { return Ok(false); }
|
||||
for name in expected {
|
||||
if !verified_equal(&selected_source(source, &name, follow_root_links)?, &target.join(name))? { return Ok(false); }
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
pub fn write_journal(path: &Path, value: &Value) -> Result<()> {
|
||||
let temporary = path.with_extension("tmp");
|
||||
fs::write(&temporary, serde_json::to_vec(value)?)?;
|
||||
fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?;
|
||||
fs::File::open(&temporary)?.sync_all()?;
|
||||
fs::rename(temporary, path)?;
|
||||
fs::File::open(path.parent().context("缺少存储状态目录")?)?.sync_all()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_target(source: &Path, target: &Path, allow_root: bool) -> Result<PathBuf> {
|
||||
ensure!(target.is_absolute() && target != Path::new("/"), "请选择绝对目录路径");
|
||||
let parent = target.parent().context("缺少目标目录的上级目录")?.canonicalize()?;
|
||||
ensure!(parent.is_dir(), "目标目录的上级目录不存在");
|
||||
let target = parent.join(target.file_name().context("无效目录名称")?);
|
||||
ensure!(!target.starts_with("/tmp") && !target.starts_with("/proc") && !target.starts_with("/sys") && !target.starts_with("/dev") && !target.starts_with("/run/framely"), "不能在临时目录或系统目录保存数据");
|
||||
ensure!(allow_root || target.starts_with("/home") || target.starts_with("/run/media"), "请选择用户目录或已挂载存储");
|
||||
let source = source.canonicalize().unwrap_or_else(|_| source.to_path_buf());
|
||||
ensure!(source != target && !source.starts_with(&target) && !target.starts_with(&source), "目标目录不能位于原目录内部或包含原目录");
|
||||
if let Ok(meta) = fs::symlink_metadata(&target) {
|
||||
ensure!(meta.is_dir() && !meta.file_type().is_symlink(), "目标必须是空目录,不能是链接");
|
||||
ensure!(fs::read_dir(&target)?.next().is_none(), "目标目录必须为空");
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
fn prepare_space(parent: &Path, needed: u64) -> Result<()> {
|
||||
let c_path = CString::new(parent.as_os_str().as_encoded_bytes())?;
|
||||
let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
ensure!(unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) } == 0, "无法检查目标存储空间");
|
||||
let available = stat.f_bavail.saturating_mul(stat.f_frsize);
|
||||
ensure!(available >= needed.saturating_add(128 * 1024 * 1024), "目标存储空间不足");
|
||||
let probe = parent.join(format!(".framely-probe-{:016x}", rand::random::<u64>()));
|
||||
fs::create_dir(&probe)?;
|
||||
let result = (|| -> Result<()> {
|
||||
let file = probe.join("file");
|
||||
fs::write(&file, b"framely")?;
|
||||
fs::set_permissions(&file, fs::Permissions::from_mode(0o600))?;
|
||||
ensure!(fs::metadata(&file)?.permissions().mode() & 0o777 == 0o600, "目标文件系统不支持 Linux 文件权限");
|
||||
fs::hard_link(&file, probe.join("hardlink"))?;
|
||||
symlink("file", probe.join("symlink"))?;
|
||||
ensure!(fs::read_link(probe.join("symlink"))? == Path::new("file"), "目标文件系统不支持符号链接");
|
||||
Ok(())
|
||||
})();
|
||||
let _ = fs::remove_dir_all(probe);
|
||||
result
|
||||
}
|
||||
|
||||
struct ReadableDirs {
|
||||
root: PathBuf,
|
||||
changed: Vec<(PathBuf, u32)>,
|
||||
}
|
||||
impl ReadableDirs {
|
||||
fn prepare(root: &Path) -> Result<Self> {
|
||||
Self::prepare_with(root, 0o500)
|
||||
}
|
||||
fn prepare_with(root: &Path, required: u32) -> Result<Self> {
|
||||
let mut guard = Self { root: root.to_path_buf(), changed: Vec::new() };
|
||||
if root.exists() { guard.walk(root, required)?; }
|
||||
Ok(guard)
|
||||
}
|
||||
fn walk(&mut self, path: &Path, required: u32) -> Result<()> {
|
||||
let meta = fs::symlink_metadata(path)?;
|
||||
if !meta.is_dir() || meta.file_type().is_symlink() { return Ok(()); }
|
||||
let mode = meta.permissions().mode() & 0o7777;
|
||||
if mode & required != required {
|
||||
ensure!(meta.uid() == unsafe { libc::geteuid() } || unsafe { libc::geteuid() } == 0,
|
||||
"存储目录权限不足,无法迁移:{}", path.display());
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode | required))?;
|
||||
self.changed.push((path.strip_prefix(&self.root)?.to_path_buf(), mode));
|
||||
}
|
||||
for entry in fs::read_dir(path)? { self.walk(&entry?.path(), required)?; }
|
||||
Ok(())
|
||||
}
|
||||
fn restore_copy(&self, copy: &Path) -> Result<()> {
|
||||
for (relative, mode) in self.changed.iter().rev() {
|
||||
fs::set_permissions(copy.join(relative), fs::Permissions::from_mode(*mode))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Drop for ReadableDirs {
|
||||
fn drop(&mut self) {
|
||||
for (relative, mode) in self.changed.iter().rev() {
|
||||
let _ = fs::set_permissions(self.root.join(relative), fs::Permissions::from_mode(*mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn remove_tree(path: &Path) -> Result<()> {
|
||||
let _readable = ReadableDirs::prepare_with(path, 0o700)?;
|
||||
fs::remove_dir_all(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn copy_verified(source: &Path, target: &Path, mut progress: impl FnMut(&str)) -> Result<()> {
|
||||
let target = validate_target(source, target, false)?;
|
||||
let readable = ReadableDirs::prepare(source)?;
|
||||
let parent = target.parent().unwrap();
|
||||
prepare_space(parent, bytes(source)?)?;
|
||||
let stage = parent.join(format!(".framely-storage-{:016x}", rand::random::<u64>()));
|
||||
fs::create_dir(&stage)?;
|
||||
let result = (|| -> Result<()> {
|
||||
progress("copying");
|
||||
if source.exists() {
|
||||
let output = Command::new("cp").arg("-a").arg("--reflink=auto")
|
||||
.arg("--").arg(source.join(".")).arg(&stage).output()?;
|
||||
ensure!(output.status.success(), "复制存储失败:{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
progress("verifying");
|
||||
let source_files = if source.exists() { inventory(source)? } else { BTreeMap::new() };
|
||||
let target_files = inventory(&stage)?;
|
||||
ensure!(source_files == target_files || (!source.exists() && target_files.len() == 1), "迁移后的文件校验失败,仍使用原位置");
|
||||
readable.restore_copy(&stage)?;
|
||||
progress("switching");
|
||||
if target.exists() { fs::remove_dir(&target)?; }
|
||||
fs::rename(&stage, &target)?;
|
||||
fs::File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() { let _ = fs::remove_dir_all(&stage); }
|
||||
result
|
||||
}
|
||||
|
||||
pub fn copy_selected(source: &Path, target: &Path, names: &[String], follow_root_links: bool, mut progress: impl FnMut(&str)) -> Result<()> {
|
||||
let target = validate_target(source, target, false)?;
|
||||
let parent = target.parent().unwrap();
|
||||
let mut needed = 0u64;
|
||||
let mut readable = Vec::new();
|
||||
for name in names {
|
||||
let from = selected_source(source, name, follow_root_links)?;
|
||||
readable.push((name.clone(), ReadableDirs::prepare(&from)?));
|
||||
needed = needed.saturating_add(bytes(&from)?);
|
||||
}
|
||||
prepare_space(parent, needed)?;
|
||||
let stage = parent.join(format!(".framely-storage-{:016x}", rand::random::<u64>()));
|
||||
fs::create_dir(&stage)?;
|
||||
let result = (|| -> Result<()> {
|
||||
progress("copying");
|
||||
for name in names {
|
||||
ensure!(!name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\'), "无效容器名称");
|
||||
let from = selected_source(source, name, follow_root_links)?;
|
||||
if !from.exists() { continue; }
|
||||
let output = Command::new("cp").args(["-a", "--reflink=auto", "--"])
|
||||
.arg(&from).arg(stage.join(name)).output()?;
|
||||
ensure!(output.status.success(), "复制容器失败:{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
progress("verifying");
|
||||
for name in names {
|
||||
let from = selected_source(source, name, follow_root_links)?;
|
||||
if from.exists() { ensure!(inventory(&from)? == inventory(&stage.join(name))?, "容器迁移校验失败:{name}"); }
|
||||
}
|
||||
for (name, guard) in &readable { if stage.join(name).exists() { guard.restore_copy(&stage.join(name))?; } }
|
||||
progress("switching");
|
||||
if target.exists() { fs::remove_dir(&target)?; }
|
||||
fs::rename(&stage, &target)?;
|
||||
fs::File::open(parent)?.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() { let _ = fs::remove_dir_all(&stage); }
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn copy_and_verify_preserve_data_and_reject_unsafe_targets() {
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let source = temp.path().join("source");
|
||||
fs::create_dir(&source).unwrap();
|
||||
fs::write(source.join("saved.apk"), b"apk data").unwrap();
|
||||
symlink("saved.apk", source.join("current")).unwrap();
|
||||
let target = temp.path().join("moved");
|
||||
let mut steps = Vec::new();
|
||||
copy_verified(&source, &target, |step| steps.push(step.to_owned())).unwrap();
|
||||
assert_eq!(steps, ["copying", "verifying", "switching"]);
|
||||
assert_eq!(fs::read(target.join("saved.apk")).unwrap(), b"apk data");
|
||||
assert_eq!(fs::read_link(target.join("current")).unwrap(), Path::new("saved.apk"));
|
||||
assert_eq!(fs::read(source.join("saved.apk")).unwrap(), b"apk data");
|
||||
assert!(validate_target(&source, &source.join("inside"), false).is_err());
|
||||
assert!(copy_verified(&source, &target, |_| {}).is_err());
|
||||
assert!(prepare_space(temp.path(), u64::MAX).is_err());
|
||||
assert_eq!(fs::read(source.join("saved.apk")).unwrap(), b"apk data");
|
||||
}
|
||||
#[test]
|
||||
fn selected_copy_excludes_external_containers() {
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let source = temp.path().join("contexts");
|
||||
fs::create_dir(&source).unwrap();
|
||||
for name in ["managed", "external"] {
|
||||
fs::create_dir(source.join(name)).unwrap();
|
||||
fs::write(source.join(name).join("state"), name).unwrap();
|
||||
}
|
||||
let work = source.join("managed/app_workdir/work");
|
||||
fs::create_dir_all(&work).unwrap();
|
||||
fs::write(work.join("overlay-state"), b"private").unwrap();
|
||||
fs::set_permissions(&work, fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let target = temp.path().join("android");
|
||||
copy_selected(&source, &target, &["managed".into()], false, |_| {}).unwrap();
|
||||
assert_eq!(fs::read_to_string(target.join("managed/state")).unwrap(), "managed");
|
||||
assert!(verified_selected(&source, &target, &["managed".into()], false).unwrap());
|
||||
fs::set_permissions(&work, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::set_permissions(target.join("managed/app_workdir/work"), fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert!(!target.join("external").exists());
|
||||
assert!(source.join("external/state").is_file());
|
||||
}
|
||||
#[test]
|
||||
fn plugin_roots_copy_real_data_instead_of_storage_links() {
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
let source = temp.path().join("state");
|
||||
let physical = temp.path().join("persistent-plugins");
|
||||
fs::create_dir(&source).unwrap();
|
||||
fs::create_dir(&physical).unwrap();
|
||||
fs::write(physical.join("manifest.json"), b"plugin").unwrap();
|
||||
symlink(&physical, source.join("plugins")).unwrap();
|
||||
let target = temp.path().join("moved");
|
||||
assert!(copy_selected(&source,&target,&["plugins".into()],false,|_|{}).is_err());
|
||||
copy_selected(&source,&target,&["plugins".into()],true,|_|{}).unwrap();
|
||||
assert!(target.join("plugins").is_dir());
|
||||
assert!(!fs::symlink_metadata(target.join("plugins")).unwrap().file_type().is_symlink());
|
||||
assert_eq!(fs::read(target.join("plugins/manifest.json")).unwrap(),b"plugin");
|
||||
assert!(verified_selected(&source,&target,&["plugins".into()],true).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn display_size_marks_unreadable_overlay_work_without_hiding_migration_error() {
|
||||
if unsafe { libc::geteuid() } == 0 { return; }
|
||||
let temp = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
|
||||
fs::write(temp.path().join("visible"), b"saved").unwrap();
|
||||
let work = temp.path().join("app_workdir/work");
|
||||
fs::create_dir_all(&work).unwrap();
|
||||
fs::write(work.join("overlay-state"), b"private").unwrap();
|
||||
fs::set_permissions(&work, fs::Permissions::from_mode(0o000)).unwrap();
|
||||
let shown = display_bytes(temp.path());
|
||||
let strict = bytes(temp.path());
|
||||
assert_eq!(shown.unwrap(), (5, true));
|
||||
assert!(strict.is_err());
|
||||
let target = temp.path().parent().unwrap().join(format!("framely-storage-test-{:016x}", rand::random::<u64>()));
|
||||
copy_verified(temp.path(), &target, |_| {}).unwrap();
|
||||
assert_eq!(fs::metadata(&work).unwrap().permissions().mode() & 0o777, 0);
|
||||
let copied_work = target.join("app_workdir/work");
|
||||
assert_eq!(fs::metadata(&copied_work).unwrap().permissions().mode() & 0o777, 0);
|
||||
assert!(verified_equal(temp.path(), &target).unwrap());
|
||||
fs::set_permissions(&work, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::set_permissions(&copied_work, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
assert_eq!(fs::read(copied_work.join("overlay-state")).unwrap(), b"private");
|
||||
fs::remove_dir_all(target).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn disk_usage_does_not_follow_links_or_count_hardlinks_twice() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let file = temp.path().join("payload");
|
||||
fs::write(&file, vec![7; 8192]).unwrap();
|
||||
fs::hard_link(&file, temp.path().join("same-blocks")).unwrap();
|
||||
symlink(&file, temp.path().join("shortcut")).unwrap();
|
||||
let (used, incomplete) = disk_bytes(temp.path()).unwrap();
|
||||
let root_blocks = fs::metadata(temp.path()).unwrap().blocks() * 512;
|
||||
let file_blocks = fs::metadata(&file).unwrap().blocks() * 512;
|
||||
let link_blocks = fs::symlink_metadata(temp.path().join("shortcut")).unwrap().blocks() * 512;
|
||||
assert_eq!(used, root_blocks + file_blocks + link_blocks);
|
||||
assert!(!incomplete);
|
||||
}
|
||||
#[test]
|
||||
fn disk_usage_follows_storage_root_link_but_not_nested_links() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let target = temp.path().join("real-plugins");
|
||||
fs::create_dir(&target).unwrap();
|
||||
fs::write(target.join("plugin"), vec![3; 8192]).unwrap();
|
||||
let external = temp.path().join("unrelated");
|
||||
fs::write(&external, vec![4; 8192]).unwrap();
|
||||
symlink(&external, target.join("nested-link")).unwrap();
|
||||
let root = temp.path().join("plugins");
|
||||
symlink(&target, &root).unwrap();
|
||||
assert_eq!(disk_bytes(&root).unwrap(), disk_bytes(&target).unwrap());
|
||||
assert!(disk_bytes(&root).unwrap().0 > 0);
|
||||
}
|
||||
}
|
||||
+61
-4
@@ -253,11 +253,12 @@ fn selected_versions_and_reinstallation_preserve_favorites() {
|
||||
*data = serde_json::to_vec(&m).unwrap();
|
||||
}
|
||||
});
|
||||
assert!(install(&mut core, &altered, json!({}))
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("同一插件版本"));
|
||||
install(&mut core, &altered, json!({})).unwrap();
|
||||
assert_eq!(core.db.plugins["test.plugin"].manifest.version, "1.0.0");
|
||||
assert_eq!(core.db.plugins["test.plugin"].manifest.name, "Changed release");
|
||||
assert!(core.db.plugins["test.plugin"].favorite);
|
||||
let disk: Value = serde_json::from_slice(&fs::read(state.join("plugins/test.plugin/versions/1.0.0/manifest.json")).unwrap()).unwrap();
|
||||
assert_eq!(disk["name"], "Changed release");
|
||||
assert!(!state
|
||||
.join("plugins/test.plugin/versions/1.0.0/public-key.hex")
|
||||
.exists());
|
||||
@@ -1145,6 +1146,60 @@ fn batch_hook_failure_and_interrupted_transaction_restore_state() {
|
||||
assert!(!restored.root.join("plugins/test.first").exists());
|
||||
}
|
||||
#[test]
|
||||
fn same_version_reinstall_restores_files_after_hook_failure_and_interruption() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _tools = fake_backend_tools(tmp.path());
|
||||
let state = tmp.path().join("state");
|
||||
let mut core = accepted_service(&state, unsafe { libc::geteuid() }).unwrap();
|
||||
let original = relation_package(tmp.path(), "test.same", "1.0.0", json!({}));
|
||||
install(&mut core, &original, json!({})).unwrap();
|
||||
let data = state.join("data/test.same/steamos/save");
|
||||
fs::create_dir_all(data.parent().unwrap()).unwrap();
|
||||
fs::write(&data, b"user data").unwrap();
|
||||
let broken = relation_package(tmp.path(), "test.same", "1.0.0", json!({"name":"broken","lifecycle":{"runAs":"steamos","onUpdate":{"entry":"fail.sh"}}}));
|
||||
assert!(install(&mut core, &broken, json!({})).is_err());
|
||||
let payload = state.join("plugins/test.same/versions/1.0.0");
|
||||
let restored: Value = serde_json::from_slice(&fs::read(payload.join("manifest.json")).unwrap()).unwrap();
|
||||
assert_eq!(restored["name"], "test.same");
|
||||
assert_eq!(fs::read(&data).unwrap(), b"user data");
|
||||
assert!(!state.join("install-transaction.json").exists());
|
||||
|
||||
let old_db = core.db.clone();
|
||||
let backup_name = ".previous-1.0.0-interrupted";
|
||||
fs::write(state.join("install-transaction.json"), serde_json::to_vec(&json!({"database":old_db,"targets":["test.same"],"running":[],"backups":{"test.same":backup_name},"replacementVersions":{"test.same":"1.0.0"}})).unwrap()).unwrap();
|
||||
fs::rename(&payload, state.join("plugins/test.same").join(backup_name)).unwrap();
|
||||
fs::create_dir(&payload).unwrap();
|
||||
fs::write(payload.join("manifest.json"), b"replaced").unwrap();
|
||||
let recovered = Service::load(&state, core.manager).unwrap();
|
||||
assert_eq!(recovered.db.plugins["test.same"].manifest.name, "test.same");
|
||||
let manifest: Value = serde_json::from_slice(&fs::read(payload.join("manifest.json")).unwrap()).unwrap();
|
||||
assert_eq!(manifest["name"], "test.same");
|
||||
assert_eq!(fs::read(&data).unwrap(), b"user data");
|
||||
}
|
||||
#[test]
|
||||
fn batch_failure_restores_same_version_payload() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let _tools = fake_backend_tools(tmp.path());
|
||||
let mut core = accepted_service(&tmp.path().join("state"), unsafe { libc::geteuid() }).unwrap();
|
||||
let old = relation_package(tmp.path(), "test.replace", "1.0.0", json!({}));
|
||||
install(&mut core, &old, json!({})).unwrap();
|
||||
let replacement = relation_package(tmp.path(), "test.replace", "1.0.0", json!({"name":"new payload"}));
|
||||
let bad = relation_package(tmp.path(), "test.fails", "1.0.0", json!({"lifecycle":{"runAs":"steamos","onInstall":{"entry":"fail.sh"}}}));
|
||||
let plan = crate::planner::Plan {
|
||||
fingerprint:crate::relations::fingerprint(&core.db).unwrap(), root:"test.fails".into(),
|
||||
items:vec![
|
||||
crate::planner::Item{manifest:package::verify(&replacement).unwrap().manifest,source:None,action:"reinstall".into(),run_as_changed:false},
|
||||
crate::planner::Item{manifest:package::verify(&bad).unwrap().manifest,source:None,action:"install".into(),run_as_changed:false},
|
||||
], add_sources:vec![], enable:vec!["test.fails".into()], disable:vec![], affected:vec!["test.replace".into(),"test.fails".into()],
|
||||
};
|
||||
let result = apply_prepared(&mut core, crate::planner::Prepared{plan,packages:vec![(package::Staged::from_bytes(&replacement).unwrap(),None),(package::Staged::from_bytes(&bad).unwrap(),None)]});
|
||||
assert!(result.is_err());
|
||||
let manifest:Value=serde_json::from_slice(&fs::read(core.root.join("plugins/test.replace/versions/1.0.0/manifest.json")).unwrap()).unwrap();
|
||||
assert_eq!(manifest["name"],"test.replace");
|
||||
assert_eq!(core.db.plugins["test.replace"].manifest.name,"test.replace");
|
||||
assert!(!core.root.join("install-transaction.json").exists());
|
||||
}
|
||||
#[test]
|
||||
fn cross_source_resolution_requires_choice_and_uses_the_chosen_package() {
|
||||
use tiny_http::{Response, Server};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -1542,6 +1597,7 @@ fn hook_failure_does_not_stop_plugin_with_hook_suffix() {
|
||||
unsafe { libc::geteuid() },
|
||||
Default::default(),
|
||||
&tmp.path().join("logs"),
|
||||
tmp.path(),
|
||||
)
|
||||
.unwrap();
|
||||
let pid = tmp.path().join("framely-backend-review.foo-hook.pid");
|
||||
@@ -1575,6 +1631,7 @@ fn hook_failure_does_not_stop_plugin_with_hook_suffix() {
|
||||
&hook_payload,
|
||||
unsafe { libc::geteuid() },
|
||||
&tmp.path().join("logs"),
|
||||
tmp.path(),
|
||||
json!({"phase":"onInstall"}),
|
||||
std::time::Duration::from_secs(1),
|
||||
);
|
||||
|
||||
@@ -20,6 +20,11 @@ struct Upload {
|
||||
touched: Instant,
|
||||
}
|
||||
impl Uploads {
|
||||
pub fn active(&self) -> bool {
|
||||
let mut slot = self.0.lock().unwrap();
|
||||
Self::expire(&mut slot);
|
||||
slot.is_some()
|
||||
}
|
||||
fn expire(slot: &mut Option<Upload>) {
|
||||
if slot
|
||||
.as_ref()
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Keep media packaging aligned with Cargo's default and overridden output paths."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class MediaBuildOutput(unittest.TestCase):
|
||||
def test_copies_compiled_binaries_from_cargo_target_directory(self):
|
||||
cargo = shutil.which('cargo')
|
||||
self.assertIsNotNone(cargo)
|
||||
for setting in (None, 'target/cargo', 'absolute'):
|
||||
with self.subTest(target=setting), tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / 'tools').mkdir()
|
||||
source = (ROOT / 'tools/build-media.sh').read_text()
|
||||
# Native dependencies are separate from the Cargo output-copy boundary.
|
||||
(root / 'tools/build-media.sh').write_text(source.split('# Native dependencies')[0])
|
||||
crate = root / 'media/capture'
|
||||
(crate / 'src').mkdir(parents=True)
|
||||
(crate / 'Cargo.toml').write_text('[package]\nname="capture-fixture"\nversion="0.1.0"\nedition="2021"\n')
|
||||
(crate / 'src/main.rs').write_text('fn main() {}\n')
|
||||
env = dict(os.environ)
|
||||
env.pop('CARGO_TARGET_DIR', None)
|
||||
env.pop('FRAMELY_SYSROOT', None)
|
||||
if setting:
|
||||
env['CARGO_TARGET_DIR'] = str(root / 'absolute-output') if setting == 'absolute' else setting
|
||||
subprocess.run([cargo, 'generate-lockfile', '--manifest-path', str(crate / 'Cargo.toml')], env=env, check=True)
|
||||
mock_bin = root / 'mock-bin'
|
||||
mock_bin.mkdir()
|
||||
wrapper = mock_bin / 'cargo'
|
||||
wrapper.write_text('''#!/usr/bin/env python3
|
||||
import json,os,pathlib,subprocess,sys
|
||||
real=os.environ['TEST_REAL_CARGO']
|
||||
if sys.argv[1]=='build':
|
||||
result=subprocess.check_output([real,'metadata','--locked','--no-deps','--format-version','1','--manifest-path','media/capture/Cargo.toml'])
|
||||
output=pathlib.Path(json.loads(result)['target_directory'])/'release'
|
||||
output.mkdir(parents=True,exist_ok=True)
|
||||
for name in ['framely-capture','framely-panel-grab']:(output/name).write_text('compiled '+name)
|
||||
else:os.execv(real,[real,*sys.argv[1:]])
|
||||
''')
|
||||
wrapper.chmod(0o755)
|
||||
env.update(TEST_REAL_CARGO=cargo, PATH=str(mock_bin) + os.pathsep + env['PATH'])
|
||||
subprocess.run(['bash', str(root / 'tools/build-media.sh')], cwd=root, env=env, check=True)
|
||||
for name in ('framely-capture', 'framely-panel-grab'):
|
||||
self.assertEqual((root / 'media/bin' / name).read_text(), 'compiled ' + name)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -16,7 +16,8 @@ fi
|
||||
cargo build --release --locked --manifest-path media/capture/Cargo.toml
|
||||
)
|
||||
mkdir -p media/bin
|
||||
cp media/capture/target/release/framely-capture media/capture/target/release/framely-panel-grab media/bin/
|
||||
capture_target=$(cargo metadata --locked --no-deps --format-version 1 --manifest-path media/capture/Cargo.toml | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')
|
||||
cp "$capture_target/release/framely-capture" "$capture_target/release/framely-panel-grab" media/bin/
|
||||
# Native dependencies use the target system's GStreamer and GUPnP libraries.
|
||||
python3 tools/fetch-media.py
|
||||
bash tools/build-gst-libav.sh
|
||||
|
||||
@@ -109,6 +109,18 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
|
||||
else:
|
||||
steam_home = pathlib.Path(steam_home)
|
||||
apk = steam_home / '.local/share/framely/apk-manager'
|
||||
storage_file = steam_home / '.local/share/framely/storage.json'
|
||||
if storage_file.is_file() and not storage_file.is_symlink() and storage_file.stat().st_uid == steam_home.stat().st_uid:
|
||||
try:
|
||||
with storage_file.open('rb') as source:
|
||||
raw = source.read(65537)
|
||||
if len(raw) > 65536:
|
||||
raise ValueError('storage setting exceeds diagnostic size limit')
|
||||
configured = json.loads(raw).get('apk')
|
||||
if isinstance(configured, str) and pathlib.Path(configured).is_absolute():
|
||||
apk = pathlib.Path(configured)
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
report['warnings'].append(f'apk: invalid storage setting: {error}')
|
||||
try:
|
||||
with (apk / 'state.json').open('rb') as source:
|
||||
raw = source.read(16 * 1024 * 1024 + 1)
|
||||
|
||||
+51
-1
@@ -1229,6 +1229,56 @@
|
||||
"使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。": "Uses headset hardware encoding. Browsers that cannot decode the selected codec receive a compatible stream.",
|
||||
"SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR uses software encoding. H.265 increases processing load.",
|
||||
"串流": "Streaming",
|
||||
"投屏设置": "Casting settings"
|
||||
"投屏设置": "Casting settings",
|
||||
"存储": "Storage",
|
||||
"管理插件、APK 文件和安卓容器的存放位置。": "Manage where plugins, APK files, and Android containers are stored.",
|
||||
"插件程序和运行数据": "Plugin files and runtime data",
|
||||
"安装包、上传文件和备份": "Installation packages, uploads, and backups",
|
||||
"Framely 创建的 Lepton 容器和应用数据": "Lepton containers and app data created by Framely",
|
||||
"准备迁移": "Preparing migration",
|
||||
"停止相关程序": "Stopping affected programs",
|
||||
"复制数据": "Copying data",
|
||||
"核对文件": "Verifying files",
|
||||
"切换目录": "Switching storage location",
|
||||
"恢复运行": "Restarting programs",
|
||||
"清理旧目录": "Cleaning up the previous location",
|
||||
"选择目标目录": "Choose a destination folder",
|
||||
"选择上级目录,Framely 会在其中创建专用文件夹。": "Choose a parent folder. Framely will create a dedicated folder inside it.",
|
||||
"此目录下没有文件夹": "No folders here",
|
||||
"选择此位置": "Choose this location",
|
||||
"迁移完成。旧目录未能清理,请检查原位置。": "Migration finished. The previous folder could not be removed; please check it.",
|
||||
"迁移完成": "Migration finished",
|
||||
"迁移失败": "Migration failed",
|
||||
"正在读取存储位置": "Loading storage locations",
|
||||
"部分目录无法读取,显示可统计的最小占用": "Some folders could not be read. This is the minimum measured usage.",
|
||||
"当前位置": "Current location",
|
||||
"取消更改": "Cancel change",
|
||||
"继续迁移": "Resume migration",
|
||||
"更改位置": "Change location",
|
||||
"当前目录不可用,请先连接存储设备。": "This folder is unavailable. Connect the storage device first.",
|
||||
"上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。": "The previous migration did not finish. The original folder is still in use. Retry the same destination or choose a new empty folder.",
|
||||
"新位置": "New location",
|
||||
"浏览": "Browse",
|
||||
"目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。": "The destination must be empty and support Linux file permissions. Affected programs will pause during migration.",
|
||||
"迁移到新位置": "Move to new location",
|
||||
"APK 文件": "APK files",
|
||||
"安卓容器": "Android containers",
|
||||
"存储占用": "Storage usage",
|
||||
"头显用户存储卷": "Headset user storage",
|
||||
"总容量": "Total capacity",
|
||||
"已使用": "Used",
|
||||
"系统及其他": "System and other",
|
||||
"空闲": "Free",
|
||||
"已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。": "Data moved to another drive appears in the details below and is excluded from this volume.",
|
||||
"部分目录无法读取,Framely 占用可能低于实际值。": "Some folders could not be read, so Framely usage may be understated.",
|
||||
"占用明细": "Usage details",
|
||||
"程序": "Program",
|
||||
"数据": "Data",
|
||||
"备份、上传文件及其他": "Backups, uploads and other files",
|
||||
"其他插件文件": "Other plugin files",
|
||||
"其他容器文件": "Other container files",
|
||||
"暂无数据": "No data yet",
|
||||
"其他磁盘": "Other drive",
|
||||
"{0} 项": "{0} items"
|
||||
}
|
||||
}
|
||||
+51
-1
@@ -1229,6 +1229,56 @@
|
||||
"使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。": "使用头显硬件编码。浏览器不支持所选编码时使用兼容画面。",
|
||||
"SteamVR 使用软件编码,H.265 会增加处理负载。": "SteamVR 使用软件编码,H.265 会增加处理负载。",
|
||||
"串流": "串流",
|
||||
"投屏设置": "投屏设置"
|
||||
"投屏设置": "投屏设置",
|
||||
"存储": "存储",
|
||||
"管理插件、APK 文件和安卓容器的存放位置。": "管理插件、APK 文件和安卓容器的存放位置。",
|
||||
"插件程序和运行数据": "插件程序和运行数据",
|
||||
"安装包、上传文件和备份": "安装包、上传文件和备份",
|
||||
"Framely 创建的 Lepton 容器和应用数据": "Framely 创建的 Lepton 容器和应用数据",
|
||||
"准备迁移": "准备迁移",
|
||||
"停止相关程序": "停止相关程序",
|
||||
"复制数据": "复制数据",
|
||||
"核对文件": "核对文件",
|
||||
"切换目录": "切换目录",
|
||||
"恢复运行": "恢复运行",
|
||||
"清理旧目录": "清理旧目录",
|
||||
"选择目标目录": "选择目标目录",
|
||||
"选择上级目录,Framely 会在其中创建专用文件夹。": "选择上级目录,Framely 会在其中创建专用文件夹。",
|
||||
"此目录下没有文件夹": "此目录下没有文件夹",
|
||||
"选择此位置": "选择此位置",
|
||||
"迁移完成。旧目录未能清理,请检查原位置。": "迁移完成。旧目录未能清理,请检查原位置。",
|
||||
"迁移完成": "迁移完成",
|
||||
"迁移失败": "迁移失败",
|
||||
"正在读取存储位置": "正在读取存储位置",
|
||||
"部分目录无法读取,显示可统计的最小占用": "部分目录无法读取,显示可统计的最小占用",
|
||||
"当前位置": "当前位置",
|
||||
"取消更改": "取消更改",
|
||||
"继续迁移": "继续迁移",
|
||||
"更改位置": "更改位置",
|
||||
"当前目录不可用,请先连接存储设备。": "当前目录不可用,请先连接存储设备。",
|
||||
"上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。": "上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。",
|
||||
"新位置": "新位置",
|
||||
"浏览": "浏览",
|
||||
"目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。": "目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。",
|
||||
"迁移到新位置": "迁移到新位置",
|
||||
"APK 文件": "APK 文件",
|
||||
"安卓容器": "安卓容器",
|
||||
"存储占用": "存储占用",
|
||||
"头显用户存储卷": "头显用户存储卷",
|
||||
"总容量": "总容量",
|
||||
"已使用": "已使用",
|
||||
"系统及其他": "系统及其他",
|
||||
"空闲": "空闲",
|
||||
"已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。": "已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。",
|
||||
"部分目录无法读取,Framely 占用可能低于实际值。": "部分目录无法读取,Framely 占用可能低于实际值。",
|
||||
"占用明细": "占用明细",
|
||||
"程序": "程序",
|
||||
"数据": "数据",
|
||||
"备份、上传文件及其他": "备份、上传文件及其他",
|
||||
"其他插件文件": "其他插件文件",
|
||||
"其他容器文件": "其他容器文件",
|
||||
"暂无数据": "暂无数据",
|
||||
"其他磁盘": "其他磁盘",
|
||||
"{0} 项": "{0} 项"
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
export type IconName='star'|'plugins'|'settings'|'arrow'|'close'|'more'|'download'|'search'|'shield'|'link'|'refresh'|'info'|'power'|'connection'|'edit'|'trash'|'store';
|
||||
export type IconName='star'|'plugins'|'settings'|'folder'|'arrow'|'close'|'more'|'download'|'search'|'shield'|'link'|'refresh'|'info'|'power'|'connection'|'edit'|'trash'|'store';
|
||||
const paths:Record<IconName,React.ReactNode>={
|
||||
folder:<path d="M3 6h7l2 2h9v12H3Z"/>,
|
||||
store:<><path d="M4 10v11h16V10M3 10l2-7h14l2 7M3 10a3 3 0 0 0 6 0 3 3 0 0 0 6 0 3 3 0 0 0 6 0M9 21v-6h6v6"/></>,
|
||||
power:<><path d="M12 3v9M7 5a8 8 0 1 0 10 0"/></>,
|
||||
connection:<><path d="M7 3v4m6-4v4M5 7h10v3a5 5 0 0 1-5 5v5M17 16l2 2 3-4"/></>,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {configureLanguage,currentLanguage,LanguagePack} from './i18n';
|
||||
import {AgreementGate,AgreementState} from './agreement';
|
||||
import {ProxySettings,ProxyConfig} from './proxy';
|
||||
import {NetworkSettings,NetworkPanel} from './network';
|
||||
import {StorageSettings} from './storage-settings';
|
||||
import {Subscriptions,Subscription} from './subscriptions';
|
||||
import {RelationsInfo} from './relations';
|
||||
import {InstallReview} from './install-review';
|
||||
@@ -113,6 +114,7 @@ function App(){
|
||||
{managerMode&&(filesVisited||tab==='files')&&<div className="file-workspace-frame" hidden={tab!=='files'}><FileManager active={tab==='files'}/></div>}
|
||||
{tab==='terminal'&&managerMode&&<TerminalManager/>}
|
||||
{managerSection==='casting'&&managerMode&&status.database.casting&&<Casting page={tab as CastPage} config={status.database.casting} refresh={refresh} network={status.database.networkPanel??{enabled:true,port:15915}}/>}
|
||||
{tab==='storage'&&managerMode&&<StorageSettings/>}
|
||||
{apkPanel&&<ApkManager view={tab==='apk-containers'?'containers':tab==='apk-cleanup'?'cleanup':tab==='apk-settings'?'settings':'apps'} onViewChange={view=>setTab(view==='apps'?'apk':'apk-'+view)}/>}
|
||||
{tab==='launcher-settings'&&managerMode&&<><div className="list-heading"><div><h1>{t('启动台')}</h1><p>{t('启动方式、菜单与分类。')}</p></div></div><LauncherSettings config={status.database.launcher} refresh={refresh}/></>}
|
||||
{tab==='notification-settings'&&managerMode&&<NotificationSettings config={status.database.notificationSettings??defaultNotificationConfig} plugins={status.database.plugins} refresh={refresh}/>}
|
||||
|
||||
@@ -6,9 +6,9 @@ export function sectionFor(tab:string):ManagerSection {
|
||||
if(['casting','casting-picture','casting-receive','casting-devices'].includes(tab))return 'casting';
|
||||
if(tab.startsWith('apk'))return 'apk';
|
||||
if(tab==='terminal'||tab==='files')return tab;
|
||||
return ['settings','launcher-settings','about','updates'].includes(tab)?'settings':'plugins';
|
||||
return ['settings','storage','launcher-settings','about','updates'].includes(tab)?'settings':'plugins';
|
||||
}
|
||||
export function managerTab(hash:string){const key=hash.slice(1);return ['casting','casting-picture','casting-receive','casting-devices','installed','catalog','sources','notification-settings','settings','launcher-settings','about','updates','terminal','files','apk','apk-containers','apk-cleanup','apk-settings'].includes(key)?key:'installed';}
|
||||
export function managerTab(hash:string){const key=hash.slice(1);return ['casting','casting-picture','casting-receive','casting-devices','installed','catalog','sources','notification-settings','settings','storage','launcher-settings','about','updates','terminal','files','apk','apk-containers','apk-cleanup','apk-settings'].includes(key)?key:'installed';}
|
||||
export function ManagerSwitcher({section,onChange}:{section:ManagerSection;onChange:(section:ManagerSection)=>void}) {
|
||||
return <div className="manager-switcher" role="group" aria-label={t('切换管理面板')}>
|
||||
{([['plugins','插件'],['apk','APK'],['terminal','终端'],['files','文件'],['casting','投屏'],['settings','设置']] as const).map(([key,label])=><button key={key} className={section===key?'active':''} aria-pressed={section===key} onClick={()=>onChange(key)}><span className="manager-section-icon">{React.createElement(({plugins:IconPuzzle,apk:IconBrandAndroid,terminal:IconTerminal2,files:IconFolder,casting:IconCast,settings:IconSettings})[key],{size:22})}</span><span>{t(label)}</span></button>)}
|
||||
|
||||
@@ -12,7 +12,7 @@ export function MobileSheet({title,onClose,children}:{title:string;onClose:()=>v
|
||||
const key=(e:KeyboardEvent)=>{if(e.key==='Escape'){if((e.target as HTMLElement)?.closest('.framely-select')?.querySelector('[aria-expanded="true"]'))return;e.preventDefault();e.stopPropagation();closeRef.current();}if(e.key==='Tab'){const list=items(),first=list[0],last=list.at(-1);if(e.shiftKey&&document.activeElement===first){e.preventDefault();last?.focus();}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first?.focus();}}};document.addEventListener('keydown',key,true);return()=>{document.removeEventListener('keydown',key,true);if(previous?.isConnected&&!document.querySelector('.modal[aria-modal="true"]'))previous.focus();};},[]);
|
||||
return <div className="mobile-sheet-backdrop" onClick={e=>{if(e.target===e.currentTarget)onClose();}}><div className="mobile-sheet" ref={ref} role="dialog" aria-modal="true" aria-label={title}><header><h2>{title}</h2><button aria-label={t('关闭')} onClick={onClose}><IconX size={22}/></button></header><div className="mobile-sheet-body">{children}</div></div></div>;
|
||||
}
|
||||
export const managerPages=(section:ManagerSection):[string,string,string][]=>section==='settings'?[['settings',t('通用'),'settings'],['launcher-settings',t('启动台'),'launcher'],['updates',t('更新'),'refresh'],['about',t('关于'),'info']]:section==='apk'?[['apk',t('应用'),'download'],['apk-containers',t('容器'),'plugins'],['apk-cleanup',t('数据清理'),'settings'],['apk-settings',t('设置'),'settings']]:section==='casting'?[['casting',t('网页观看'),'connection'],['casting-picture',t('画面与声音'),'settings'],['casting-receive',t('接收投屏'),'download'],['casting-devices',t('投到设备'),'connection']]:section==='plugins'?[['installed',t('已安装'),'plugins'],['catalog',t('插件库'),'download'],['sources',t('插件源导航'),'link'],['notification-settings',t('通知设置导航'),'bell']]:[];
|
||||
export const managerPages=(section:ManagerSection):[string,string,string][]=>section==='settings'?[['settings',t('通用'),'settings'],['storage',t('存储'),'folder'],['launcher-settings',t('启动台'),'launcher'],['updates',t('更新'),'refresh'],['about',t('关于'),'info']]:section==='apk'?[['apk',t('应用'),'download'],['apk-containers',t('容器'),'plugins'],['apk-cleanup',t('数据清理'),'settings'],['apk-settings',t('设置'),'settings']]:section==='casting'?[['casting',t('网页观看'),'connection'],['casting-picture',t('画面与声音'),'settings'],['casting-receive',t('接收投屏'),'download'],['casting-devices',t('投到设备'),'connection']]:section==='plugins'?[['installed',t('已安装'),'plugins'],['catalog',t('插件库'),'download'],['sources',t('插件源导航'),'link'],['notification-settings',t('通知设置导航'),'bell']]:[];
|
||||
export function MobilePagePicker({section,tab,onChange}:{section:ManagerSection;tab:string;onChange:(tab:string)=>void}){
|
||||
const [open,setOpen]=useState(false),pages=managerPages(section),label=pages.find(p=>p[0]===tab)?.[1]??t(section==='terminal'?'终端':section==='casting'?'投屏':'文件');
|
||||
useEffect(()=>setOpen(false),[section,tab]);
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
.storage-settings{width:100%;min-width:0}
|
||||
.storage-settings>.list-heading{margin-bottom:28px}
|
||||
.storage-section{margin-bottom:0}
|
||||
.storage-overview{margin-bottom:16px}
|
||||
.storage-overview-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}
|
||||
.storage-overview-heading h2{margin:0 0 4px}
|
||||
.storage-overview-heading p{margin:0}
|
||||
.storage-overview-total{display:grid;text-align:right;line-height:1.2}
|
||||
.storage-overview-total strong{font-size:30px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.04em}
|
||||
.storage-overview-total span{margin-top:6px;color:var(--muted);font-size:15px}
|
||||
.storage-meter{display:flex;width:100%;height:15px;margin-top:28px;border-radius:100px;overflow:hidden;background:#34383e}
|
||||
.storage-meter span{height:100%;flex:none;min-width:0}
|
||||
.storage-meter-plugin,.storage-dot-plugin{background:#93c5ed}
|
||||
.storage-meter-apk,.storage-dot-apk{background:#79aaa4}
|
||||
.storage-meter-contexts,.storage-dot-contexts{background:#c1a879}
|
||||
.storage-meter-other,.storage-dot-other{background:#626871}
|
||||
.storage-meter-free,.storage-dot-free{background:#34383e}
|
||||
.storage-overview-usage{display:flex;justify-content:space-between;gap:16px;margin:11px 0 25px;color:var(--muted);font-size:15px}
|
||||
.storage-overview-usage strong{color:var(--text);font-weight:550;font-variant-numeric:tabular-nums}
|
||||
.storage-legend{display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:0 22px;border-top:1px solid var(--line)}
|
||||
.storage-legend-item{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 0;border-bottom:1px solid var(--line);font-size:16px}
|
||||
.storage-legend-item i{width:9px;height:9px;border-radius:50%;flex:none}
|
||||
.storage-legend-item span{min-width:0;flex:1;color:var(--muted)}
|
||||
.storage-legend-item small{font-size:13px;color:var(--muted)}
|
||||
.storage-legend-item strong{font-weight:550;white-space:nowrap;font-variant-numeric:tabular-nums}
|
||||
.storage-overview-note{margin:15px 0 0;color:var(--muted);font-size:15px;line-height:1.5}
|
||||
/* Category identity stays in the left rail; paths and drill-down data live together on the right. */
|
||||
.storage-section{padding:32px 0 36px;margin:0}
|
||||
.storage-section-layout{display:grid;grid-template-columns:minmax(210px,27%) minmax(0,1fr);column-gap:clamp(28px,5vw,88px)}
|
||||
.storage-section-heading{display:flex;flex-direction:column;align-items:flex-start;min-width:0}
|
||||
.storage-kind-icon{display:grid;place-items:center;width:46px;height:46px;margin-bottom:17px;border:1px solid #4a5560;border-radius:5px;color:var(--blue);background:#93c5ed10}
|
||||
.storage-kind-apk{color:#9ac6c0;border-color:#445953;background:#79aaa410}
|
||||
.storage-kind-contexts{color:#dbc294;border-color:#605543;background:#c1a87910}
|
||||
.storage-section-heading h2{margin:0 0 5px;font-size:25px;line-height:1.2}
|
||||
.storage-section-heading .sub{max-width:260px;margin:0;color:var(--muted);font-size:16px;line-height:1.45}
|
||||
.storage-size{margin-top:24px;color:var(--text);font-size:29px;font-weight:600;line-height:1.15;letter-spacing:-.035em;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.storage-section-content{min-width:0}
|
||||
.storage-location{display:grid;grid-template-columns:22px minmax(0,1fr) auto;align-items:center;gap:15px;min-width:0;min-height:84px;padding:15px 0;border-bottom:1px solid var(--line)}
|
||||
.storage-location>svg{align-self:start;margin-top:8px;color:var(--muted)}
|
||||
.storage-location>div{display:grid;gap:5px;min-width:0}
|
||||
.storage-location span{color:var(--muted);font-size:14px;line-height:1.2}
|
||||
.storage-location code,.storage-warning code{font:16px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere;word-break:break-word}
|
||||
.storage-location button{min-height:44px;padding:9px 14px;flex:none;white-space:nowrap;font-size:16px}
|
||||
.storage-section .storage-details{margin:0;padding:0;border:0}
|
||||
.storage-section .storage-details summary{display:flex;align-items:center;gap:12px;min-height:58px;padding:10px 0;color:var(--text);font-size:16px;font-weight:550;list-style:none;cursor:pointer}
|
||||
.storage-section .storage-details summary::-webkit-details-marker{display:none}
|
||||
.storage-section .storage-details summary:hover{color:var(--blue)}
|
||||
.storage-section .storage-details summary small{margin-left:auto;color:var(--muted);font-size:14px;font-weight:400;font-variant-numeric:tabular-nums}
|
||||
.storage-section .storage-details summary svg{color:var(--muted);transition:transform .15s ease}
|
||||
.storage-section .storage-details[open] summary svg{transform:rotate(90deg);color:var(--blue)}
|
||||
.storage-detail-list{margin:0 0 8px 22px;padding:3px 18px 3px 20px;border-left:2px solid #536273;background:#ffffff07}
|
||||
.storage-detail-row{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:20px;min-height:61px;padding:11px 0}
|
||||
.storage-detail-row+.storage-detail-row{border-top:1px solid #ffffff14}
|
||||
.storage-detail-row>div{display:flex;flex-direction:column;min-width:0;gap:3px}
|
||||
.storage-detail-row strong{font-size:16px;font-weight:550;line-height:1.4;overflow-wrap:anywhere}
|
||||
.storage-detail-row small{font-size:13px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.storage-detail-row>span{flex:none;font-size:16px;font-weight:550;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.storage-detail-other strong{color:var(--muted)}
|
||||
.storage-empty{margin:0;padding:18px 0;color:var(--muted);font-size:16px}
|
||||
.storage-warning{margin:14px 0 0;padding:12px 16px;border-left:3px solid #c6a779;background:#2d2923;color:#ecd7b8;font-size:16px;line-height:1.5}
|
||||
.storage-warning code{display:block;margin-top:6px;color:inherit}
|
||||
.storage-change{margin:0;padding:20px 0 22px;border-bottom:1px solid var(--line)}
|
||||
.storage-change label{margin:0 0 10px;font-size:16px;font-weight:550}
|
||||
.storage-target{display:flex;align-items:stretch;gap:10px}
|
||||
.storage-target input{min-width:0;flex:1;width:auto!important;margin:0!important;font:16px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
.storage-target button{flex:none;white-space:nowrap;font-size:16px}
|
||||
.storage-change>.sub{margin:10px 0 0;font-size:14px;line-height:1.5}
|
||||
.storage-actions{display:flex;justify-content:flex-end;margin-top:17px}
|
||||
.storage-actions button{white-space:nowrap;font-size:16px}
|
||||
.storage-progress{display:flex;align-items:center;gap:10px;margin:14px 0;padding:12px 0;color:var(--blue);font-size:16px}
|
||||
.storage-location button:active:not(:disabled),.storage-target button:active:not(:disabled),.storage-actions button:active:not(:disabled){transform:scale(.98)}
|
||||
@media(prefers-reduced-motion:no-preference){.storage-progress svg{animation:storage-turn 1.4s linear infinite}}
|
||||
@keyframes storage-turn{to{transform:rotate(360deg)}}
|
||||
.storage-loading{display:grid}
|
||||
.storage-loading>div{display:grid;gap:14px;padding:29px 0;border-top:1px solid var(--line)}
|
||||
.storage-loading i,.storage-loading span,.storage-loading small{display:block;height:19px;border-radius:3px;background:#ffffff12}
|
||||
.storage-loading i{width:150px;height:26px}.storage-loading span{width:min(310px,65%)}.storage-loading small{width:min(460px,80%)}
|
||||
.storage-picker-backdrop{position:fixed;inset:0;z-index:30;display:grid;place-items:center;padding:16px;background:#000a}
|
||||
.storage-picker{display:flex;flex-direction:column;gap:16px;width:min(720px,100%);max-height:min(760px,calc(100dvh - 32px));padding:30px;background:#202020;border:1px solid #626974;border-radius:6px;box-shadow:0 24px 70px #0008;overflow:hidden}
|
||||
.storage-picker header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}
|
||||
.storage-picker header h2{font-size:28px;margin:0 0 4px}
|
||||
.storage-picker header p{margin:0;color:var(--muted);font-size:17px}
|
||||
.storage-picker header .icon-button{margin:-8px -8px 0 0}
|
||||
.storage-picker-location{display:flex;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:4px;background:#171717}
|
||||
.storage-picker-location .icon-button{min-width:42px;min-height:42px;padding:8px}
|
||||
.storage-picker-location code{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font:16px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
.storage-picker-roots{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.storage-picker-roots button{min-height:42px;padding:8px 12px;font-size:16px}
|
||||
.storage-picker-folders{min-height:150px;overflow:auto;border-top:1px solid var(--line)}
|
||||
.storage-picker-folders button{display:flex;justify-content:flex-start;width:100%;min-height:54px;padding:10px 12px;border:0;border-bottom:1px solid var(--line);border-radius:0;background:transparent;text-align:left}
|
||||
.storage-picker-folders button:hover:not(:disabled){background:var(--control-hover)}
|
||||
.storage-picker-folders p{padding:22px 10px}
|
||||
.storage-picker footer{display:flex;justify-content:flex-end;gap:10px;padding-top:14px;border-top:1px solid var(--line)}
|
||||
.storage-picker .error{margin:0}
|
||||
@media(max-width:900px){.storage-section-layout{grid-template-columns:1fr;gap:20px}.storage-section-heading{display:grid;grid-template-columns:46px minmax(0,1fr) auto;column-gap:14px;align-items:center}.storage-kind-icon{margin:0;grid-row:1 / span 2}.storage-section-heading h2{font-size:22px}.storage-section-heading .sub{font-size:15px;max-width:none}.storage-size{margin:0;grid-column:3;grid-row:1 / span 2;font-size:22px}.storage-section-content{padding-left:60px}}
|
||||
@media(max-width:700px){.storage-overview-total strong{font-size:24px}.storage-legend{grid-template-columns:1fr 1fr;gap:0 14px}.storage-legend-item{font-size:14px;gap:7px}.storage-legend-item strong{font-size:14px}.storage-section{padding:27px 0 31px}.storage-section-layout{gap:16px}.storage-section-content{padding-left:0}.storage-location{grid-template-columns:20px minmax(0,1fr);gap:12px}.storage-location>svg{margin-top:4px}.storage-location button{grid-column:2;justify-self:start;margin-top:4px}.storage-detail-list{margin-left:8px;padding-left:16px;padding-right:8px}.storage-target{flex-wrap:wrap}.storage-target input{flex-basis:100%;width:100%!important}.storage-target button{width:100%}.storage-actions button{width:100%}.storage-picker{max-height:calc(100dvh - 24px);padding:20px}.storage-picker footer button{flex:1;min-width:0;white-space:nowrap}}
|
||||
@media(prefers-reduced-motion:reduce){.storage-section .storage-details summary svg{transition:none}}
|
||||
@@ -0,0 +1,210 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {IconArrowLeft, IconChevronRight, IconDeviceMobile, IconFolder, IconFolderOpen, IconLoader2, IconPackage, IconPuzzle, IconX} from '@tabler/icons-react';
|
||||
import {api} from './api';
|
||||
import {managerApi} from './manager-api';
|
||||
import {t} from './i18n';
|
||||
import './storage-settings.css';
|
||||
|
||||
type Kind = 'plugin' | 'apk' | 'contexts';
|
||||
type Detail = {id: string; name: string; bytes: number; programBytes?: number; dataBytes?: number; sizeIncomplete?: boolean};
|
||||
type Location = {path: string; bytes: number; device?: number | null; details?: Detail[]; sizeIncomplete?: boolean; available?: boolean; interrupted?: string | null};
|
||||
type Places = Record<Kind, Location>;
|
||||
type Volume = {total: number; free: number; device: number};
|
||||
type Job = {phase: string; step?: string; error?: string; result?: {path: string; cleanupWarnings?: string[]}};
|
||||
type Listing = {path: string; parent: string | null; roots: {name: string; path: string}[]; entries: {name: string; path: string; directory: boolean}[]};
|
||||
|
||||
const kinds: Kind[] = ['plugin', 'apk', 'contexts'];
|
||||
const kindIcons = {plugin: IconPuzzle, apk: IconPackage, contexts: IconDeviceMobile};
|
||||
const labels: Record<Kind, {title: string; description: string; folder: string}> = {
|
||||
plugin: {title: '插件', description: '插件程序和运行数据', folder: 'Framely-Plugins'},
|
||||
apk: {title: 'APK 文件', description: '安装包、上传文件和备份', folder: 'Framely-APK'},
|
||||
contexts: {title: '安卓容器', description: 'Framely 创建的 Lepton 容器和应用数据', folder: 'Framely-Android'},
|
||||
};
|
||||
const steps: Record<string, string> = {
|
||||
queued: '准备迁移', loading: '准备迁移', stopping: '停止相关程序',
|
||||
copying: '复制数据', verifying: '核对文件', switching: '切换目录',
|
||||
restarting: '恢复运行', cleaning: '清理旧目录',
|
||||
};
|
||||
function size(n: number) {
|
||||
return n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KiB`
|
||||
: n < 1073741824 ? `${(n / 1048576).toFixed(1)} MiB` : `${(n / 1073741824).toFixed(1)} GiB`;
|
||||
}
|
||||
async function browse(path: string): Promise<Listing> {
|
||||
return managerApi<Listing>('files', {operation: 'list', path: path || undefined, hidden: false});
|
||||
}
|
||||
|
||||
function FolderBrowser({close, choose}: {close: () => void; choose: (path: string) => void}) {
|
||||
const [listing, setListing] = useState<Listing | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
async function open(path: string) {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try { setListing(await browse(path)); }
|
||||
catch (e) { setError(String(e)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
useEffect(() => { void open(''); }, []);
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') close(); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [close]);
|
||||
return <div className="storage-picker-backdrop" onClick={event => { if (event.target === event.currentTarget) close(); }}>
|
||||
<div className="storage-picker" role="dialog" aria-modal="true" aria-label={t('选择目标目录')}>
|
||||
<header><div><h2>{t('选择目标目录')}</h2><p>{t('选择上级目录,Framely 会在其中创建专用文件夹。')}</p></div><button className="icon-button" aria-label={t('关闭')} onClick={close}><IconX size={22}/></button></header>
|
||||
<div className="storage-picker-location"><button className="icon-button" disabled={!listing?.parent || loading} onClick={() => listing?.parent && void open(listing.parent)} aria-label={t('上级目录')}><IconArrowLeft size={22}/></button><code title={listing?.path}>{listing?.path ?? t('正在读取目录…')}</code></div>
|
||||
{!!listing?.roots.length && <div className="storage-picker-roots">{listing.roots.map(root => <button key={root.path} disabled={loading} onClick={() => void open(root.path)}>{root.name}</button>)}</div>}
|
||||
<div className="storage-picker-folders">{listing?.entries.filter(entry => entry.directory).map(entry => <button key={entry.path} disabled={loading} onClick={() => void open(entry.path)}><IconFolder size={21}/><span>{entry.name}</span></button>)}{!loading && listing && !listing.entries.some(entry => entry.directory) && <p className="sub">{t('此目录下没有文件夹')}</p>}{loading && <p className="sub" role="status">{t('正在读取目录…')}</p>}</div>
|
||||
{error && <p role="alert" className="error">{error}</p>}
|
||||
<footer><button onClick={close}>{t('取消')}</button><button className="primary" disabled={!listing || loading} onClick={() => { choose(listing!.path); close(); }}>{t('选择此位置')}</button></footer>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function StorageSettings() {
|
||||
const [places, setPlaces] = useState<Places | null>(null);
|
||||
const [volume, setVolume] = useState<Volume | null>(null);
|
||||
const [paths, setPaths] = useState<Record<Kind, string>>({plugin: '', apk: '', contexts: ''});
|
||||
const [editing, setEditing] = useState<Kind | null>(null);
|
||||
const [picker, setPicker] = useState<Kind | null>(null);
|
||||
const [active, setActive] = useState<Kind | null>(null);
|
||||
const [job, setJob] = useState<{kind: Kind; id: string} | null>(null);
|
||||
const [step, setStep] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function refresh() {
|
||||
const [plugin, other] = await Promise.all([
|
||||
api<Location>('storage.plugin.status'),
|
||||
api<{apk: Location; contexts: Location; volume: Volume; interrupted?: {kind: Kind; target: string} | null}>('apk.storage.status'),
|
||||
]);
|
||||
const next: Places = {
|
||||
plugin,
|
||||
apk: {...other.apk, interrupted: other.interrupted?.kind === 'apk' ? other.interrupted.target : null},
|
||||
contexts: {...other.contexts, interrupted: other.interrupted?.kind === 'contexts' ? other.interrupted.target : null},
|
||||
};
|
||||
setPlaces(next);
|
||||
setVolume(other.volume);
|
||||
setPaths(previous => ({
|
||||
plugin: previous.plugin || plugin.interrupted || plugin.path,
|
||||
apk: previous.apk || next.apk.interrupted || next.apk.path,
|
||||
contexts: previous.contexts || next.contexts.interrupted || next.contexts.path,
|
||||
}));
|
||||
}
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try { await refresh(); }
|
||||
catch (e) { setError(String(e)); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
useEffect(() => { void reload(); }, []);
|
||||
useEffect(() => {
|
||||
if (!job) return;
|
||||
let alive = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const value = await api<Job>(job.kind === 'plugin' ? 'storage.plugin.job' : 'job.status', {job: job.id});
|
||||
if (!alive) return;
|
||||
setStep(value.step ?? value.phase);
|
||||
if (value.phase === 'done') {
|
||||
setJob(null); setActive(null); setEditing(null);
|
||||
setNotice(value.result?.cleanupWarnings?.length ? t('迁移完成。旧目录未能清理,请检查原位置。') : t('迁移完成'));
|
||||
setPaths(previous => ({...previous, [job.kind]: value.result?.path ?? previous[job.kind]}));
|
||||
await refresh();
|
||||
} else if (value.phase === 'failed' || value.phase === 'cancelled') {
|
||||
setJob(null); setActive(null);
|
||||
setError(value.error ?? t('迁移失败'));
|
||||
await refresh();
|
||||
}
|
||||
} catch (e) { if (alive) setError(String(e)); }
|
||||
};
|
||||
void poll();
|
||||
const timer = window.setInterval(() => void poll(), 750);
|
||||
return () => { alive = false; window.clearInterval(timer); };
|
||||
}, [job]);
|
||||
async function migrate(kind: Kind) {
|
||||
setError(''); setNotice(''); setActive(kind);
|
||||
try {
|
||||
const result = await api<{job: string}>(kind === 'plugin' ? 'storage.plugin.start' : 'apk.storage.start', kind === 'plugin' ? {path: paths[kind]} : {kind, path: paths[kind]});
|
||||
setJob({kind, id: result.job}); setStep('queued');
|
||||
} catch (e) { setError(String(e)); setActive(null); }
|
||||
}
|
||||
function edit(kind: Kind) {
|
||||
setError(''); setNotice(''); setEditing(kind);
|
||||
if (places) setPaths(previous => ({...previous, [kind]: places[kind].interrupted || places[kind].path}));
|
||||
}
|
||||
return <div className="storage-settings">
|
||||
<div className="list-heading"><div><h1>{t('存储')}</h1><p>{t('管理插件、APK 文件和安卓容器的存放位置。')}</p></div></div>
|
||||
{notice && <p className="banner" role="status">{notice}</p>}
|
||||
{error && <p className="error" role="alert">{error}{!places && <button onClick={() => void reload()}>{t('重试')}</button>}</p>}
|
||||
{loading && !places && <div className="storage-loading" role="status" aria-label={t('正在读取存储位置')}>{kinds.map(kind => <div key={kind}><i/><span/><small/></div>)}</div>}
|
||||
{places && volume && (() => {
|
||||
const onVolume = (kind: Kind) => places[kind].bytes === 0 || places[kind].device === volume.device;
|
||||
const managed = kinds.reduce((sum, kind) => sum + (onVolume(kind) ? places[kind].bytes : 0), 0);
|
||||
const free = Math.min(volume.free, volume.total);
|
||||
const used = Math.max(0, volume.total - free);
|
||||
const other = Math.max(0, used - managed);
|
||||
const segments = [
|
||||
...kinds.map(kind => ({key: kind, label: t(labels[kind].title), bytes: onVolume(kind) ? places[kind].bytes : 0, displayBytes: places[kind].bytes, moved: !onVolume(kind)})),
|
||||
{key: 'other', label: t('系统及其他'), bytes: other, displayBytes: other, moved: false},
|
||||
{key: 'free', label: t('空闲'), bytes: free, displayBytes: free, moved: false},
|
||||
];
|
||||
return <section className="settings-card storage-overview" aria-labelledby="storage-overview-title">
|
||||
<div className="storage-overview-heading"><div><h2 id="storage-overview-title">{t('存储占用')}</h2><p className="sub">{t('头显用户存储卷')}</p></div><div className="storage-overview-total"><strong>{size(volume.total)}</strong><span>{t('总容量')}</span></div></div>
|
||||
<div className="storage-meter" role="img" aria-label={`${t('已使用')} ${size(used)},${t('空闲')} ${size(free)}`}>{segments.map(segment => <span key={segment.key} className={`storage-meter-${segment.key}`} style={{width: `${volume.total ? segment.bytes / volume.total * 100 : 0}%`}} title={`${segment.label} ${size(segment.bytes)}`}/>)}</div>
|
||||
<div className="storage-overview-usage"><span>{t('已使用')} <strong>{size(used)}</strong></span><span>{t('空闲')} <strong>{size(free)}</strong></span></div>
|
||||
<div className="storage-legend">{segments.map(segment => <div key={segment.key} className="storage-legend-item"><i className={`storage-dot-${segment.key}`}/><span>{segment.label}{segment.moved && <small> · {t('其他磁盘')}</small>}</span><strong>{size(segment.displayBytes)}</strong></div>)}</div>
|
||||
{kinds.some(kind => !onVolume(kind)) && <p className="storage-overview-note">{t('已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。')}</p>}
|
||||
{kinds.some(kind => places[kind].sizeIncomplete) && <p className="storage-overview-note">{t('部分目录无法读取,Framely 占用可能低于实际值。')}</p>}
|
||||
</section>;
|
||||
})()}
|
||||
{places && kinds.map(kind => {
|
||||
const place = places[kind];
|
||||
const busy = !!job || !!active;
|
||||
const changing = editing === kind;
|
||||
const migrating = active === kind;
|
||||
const unchanged = !paths[kind] || paths[kind] === place.path;
|
||||
const details = [...(place.details ?? [])].sort((a, b) => b.bytes - a.bytes);
|
||||
const remainder = Math.max(0, place.bytes - details.reduce((sum, detail) => sum + detail.bytes, 0));
|
||||
const detailCount = details.length + (remainder > 0 ? 1 : 0);
|
||||
const KindIcon = kindIcons[kind];
|
||||
return <section className="settings-card storage-section" key={kind} aria-labelledby={`storage-${kind}`}>
|
||||
<div className="storage-section-layout">
|
||||
<div className="storage-section-heading">
|
||||
<span className={`storage-kind-icon storage-kind-${kind}`} aria-hidden="true"><KindIcon size={26} stroke={1.7}/></span>
|
||||
<div><h2 id={`storage-${kind}`}>{t(labels[kind].title)}</h2><p className="sub">{t(labels[kind].description)}</p></div>
|
||||
<span className="storage-size" title={place.sizeIncomplete ? t('部分目录无法读取,显示可统计的最小占用') : undefined}>{place.sizeIncomplete ? '≥ ' : ''}{size(place.bytes)}</span>
|
||||
</div>
|
||||
<div className="storage-section-content">
|
||||
<div className="storage-location">
|
||||
<IconFolder size={22} stroke={1.7} aria-hidden="true"/>
|
||||
<div><span>{t('当前位置')}</span><code title={place.path}>{place.path}</code></div>
|
||||
<button disabled={busy || place.available === false} onClick={() => changing ? setEditing(null) : edit(kind)}>{changing ? t('取消更改') : place.interrupted ? t('继续迁移') : t('更改位置')}</button>
|
||||
</div>
|
||||
{place.available === false && <p className="storage-warning" role="status">{t('当前目录不可用,请先连接存储设备。')}</p>}
|
||||
{place.interrupted && <p className="storage-warning" role="status">{t('上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。')}<code>{place.interrupted}</code></p>}
|
||||
{changing && <div className="storage-change">
|
||||
<label htmlFor={`storage-target-${kind}`}>{t('新位置')}</label>
|
||||
<div className="storage-target"><input id={`storage-target-${kind}`} value={paths[kind]} disabled={busy} onChange={event => setPaths(previous => ({...previous, [kind]: event.target.value}))} spellCheck={false}/><button disabled={busy} onClick={() => setPicker(kind)}><IconFolderOpen size={21}/>{t('浏览')}</button></div>
|
||||
<p className="sub">{t('目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。')}</p>
|
||||
<div className="storage-actions"><button className="primary" disabled={busy || place.available === false || unchanged} onClick={() => void migrate(kind)}>{t('迁移到新位置')}</button></div>
|
||||
</div>}
|
||||
{migrating && <div className="storage-progress" role="status"><IconLoader2 size={20}/><span>{t(steps[step] ?? step)}</span></div>}
|
||||
<details className="storage-details">
|
||||
<summary><span>{t('占用明细')}</span><small>{t('{0} 项', {'0': detailCount})}</small><IconChevronRight size={19} aria-hidden="true"/></summary>
|
||||
<div className="storage-detail-list">
|
||||
{details.map(detail => <div className="storage-detail-row" key={detail.id}><div><strong>{detail.name || detail.id}</strong>{detail.id !== detail.name && <small title={detail.id}>{detail.id}</small>}{kind === 'plugin' && <small>{t('程序')} {size(detail.programBytes ?? 0)} · {t('数据')} {size(detail.dataBytes ?? 0)}</small>}</div><span title={detail.sizeIncomplete ? t('部分目录无法读取,显示可统计的最小占用') : undefined}>{detail.sizeIncomplete ? '≥ ' : ''}{size(detail.bytes)}</span></div>)}
|
||||
{remainder > 0 && <div className="storage-detail-row storage-detail-other"><div><strong>{t(kind === 'apk' ? '备份、上传文件及其他' : kind === 'plugin' ? '其他插件文件' : '其他容器文件')}</strong></div><span>{size(remainder)}</span></div>}
|
||||
{detailCount === 0 && <p className="storage-empty">{t('暂无数据')}</p>}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
})}
|
||||
{picker && <FolderBrowser close={() => setPicker(null)} choose={parent => setPaths(previous => ({...previous, [picker]: `${parent.replace(/\/$/, '')}/${labels[picker].folder}`}))}/>}
|
||||
</div>;
|
||||
}
|
||||
Reference in new issue
Block a user