mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-29 04:02:47 +01:00
Show storage usage details and refine storage settings for 0.7.1
This commit is contained in:
1 parent
a9d61df041
commit
d3628b0bcf
11 files changed
+328
-48
No files matched your search
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.7.0-preview.2"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.7.0-preview.2"
|
||||
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.2`), `.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.2
|
||||
git push origin v0.7.0-preview.2
|
||||
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.2`);`.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.2
|
||||
git push origin v0.7.0-preview.2
|
||||
git tag v0.7.1
|
||||
git push origin v0.7.1
|
||||
|
||||
# 独立发布安装器,不触发 Framely 构建
|
||||
git tag installer-v0.4.1-preview.10
|
||||
|
||||
+45
-3
@@ -1003,20 +1003,40 @@ pub fn storage_status(home: &Path) -> Result<Value> {
|
||||
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::display_bytes(&contexts.join(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::display_bytes(&apk)?;
|
||||
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},"contexts":{"path":contexts,"bytes":context_bytes,"sizeIncomplete":contexts_incomplete},"interrupted":if committed {None} else {journal}}))
|
||||
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"), "无效存储类型");
|
||||
@@ -1100,6 +1120,28 @@ pub fn migrate_storage(home: &Path, kind: &str, target: &Path, cancel: Cancellat
|
||||
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();
|
||||
|
||||
+12
-1
@@ -793,7 +793,18 @@ impl Service {
|
||||
}
|
||||
let id = p["plugin"].as_str().unwrap_or("").to_owned();
|
||||
match method {
|
||||
"storage.plugin.status" => Ok(json!({"path":self.plugin_store(),"bytes":crate::storage_location::bytes(&self.plugin_store().join("plugins"))? + crate::storage_location::bytes(&self.plugin_store().join("data"))?,"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())})),
|
||||
"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())? {
|
||||
|
||||
+95
-2
@@ -3,6 +3,71 @@ 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,
|
||||
@@ -23,8 +88,8 @@ pub fn bytes(path: &Path) -> Result<u64> {
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
/// The settings page can show a lower bound when a container contains
|
||||
/// root-owned overlay work directories. Migration still uses strict `bytes`.
|
||||
/// 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,
|
||||
@@ -364,4 +429,32 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -1262,6 +1262,23 @@
|
||||
"目标须为空目录,并支持 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"
|
||||
"安卓容器": "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"
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -1262,6 +1262,23 @@
|
||||
"目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。": "目标须为空目录,并支持 Linux 文件权限。迁移期间相关程序会暂时停止。",
|
||||
"迁移到新位置": "迁移到新位置",
|
||||
"APK 文件": "APK 文件",
|
||||
"安卓容器": "安卓容器"
|
||||
"安卓容器": "安卓容器",
|
||||
"存储占用": "存储占用",
|
||||
"头显用户存储卷": "头显用户存储卷",
|
||||
"总容量": "总容量",
|
||||
"已使用": "已使用",
|
||||
"系统及其他": "系统及其他",
|
||||
"空闲": "空闲",
|
||||
"已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。": "已迁移到其他磁盘的数据仅显示在下方明细,不计入此卷。",
|
||||
"部分目录无法读取,Framely 占用可能低于实际值。": "部分目录无法读取,Framely 占用可能低于实际值。",
|
||||
"占用明细": "占用明细",
|
||||
"程序": "程序",
|
||||
"数据": "数据",
|
||||
"备份、上传文件及其他": "备份、上传文件及其他",
|
||||
"其他插件文件": "其他插件文件",
|
||||
"其他容器文件": "其他容器文件",
|
||||
"暂无数据": "暂无数据",
|
||||
"其他磁盘": "其他磁盘",
|
||||
"{0} 项": "{0} 项"
|
||||
}
|
||||
}
|
||||
+69
-18
@@ -1,25 +1,74 @@
|
||||
.storage-settings{width:100%;min-width:0}
|
||||
.storage-settings>.list-heading{margin-bottom:28px}
|
||||
.storage-section{margin-bottom:0}
|
||||
.storage-section-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}
|
||||
.storage-section-heading h2{margin:0 0 4px}
|
||||
.storage-section-heading .sub{margin:0;font-size:18px}
|
||||
.storage-size{flex:none;color:var(--muted);font-size:18px;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||||
.storage-location{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-top:24px}
|
||||
.storage-location>div{display:grid;gap:7px;min-width:0}
|
||||
.storage-location span{color:var(--muted);font-size:16px}
|
||||
.storage-location code,.storage-warning code{font:17px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere;word-break:break-word}
|
||||
.storage-location button{flex:none}
|
||||
.storage-warning{margin:18px 0 0;padding:12px 16px;border-left:3px solid #c6a779;background:#2d2923;color:#ecd7b8;font-size:17px;line-height:1.5}
|
||||
.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-top:24px;padding-top:24px;border-top:1px solid var(--line);max-width:860px}
|
||||
.storage-change label{margin:0 0 9px;font-size:18px}
|
||||
.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:17px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace}
|
||||
.storage-target button{flex:none}
|
||||
.storage-change>.sub{margin:10px 0 0;font-size:16px;line-height:1.5}
|
||||
.storage-actions{display:flex;justify-content:flex-end;margin-top:18px}
|
||||
.storage-progress{display:flex;align-items:center;gap:10px;margin-top:18px;color:var(--blue);font-size:17px}
|
||||
.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}
|
||||
@@ -43,4 +92,6 @@
|
||||
.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:700px){.storage-section-heading{gap:12px}.storage-size{font-size:16px}.storage-location{align-items:flex-start;flex-direction:column;gap:15px}.storage-location button{align-self:flex-start}.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(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}}
|
||||
+63
-14
@@ -1,17 +1,20 @@
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {IconArrowLeft, IconFolder, IconFolderOpen, IconLoader2, IconX} from '@tabler/icons-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 Location = {path: string; bytes: number; sizeIncomplete?: boolean; available?: boolean; interrupted?: string | null};
|
||||
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'},
|
||||
@@ -61,6 +64,7 @@ function FolderBrowser({close, choose}: {close: () => void; choose: (path: strin
|
||||
|
||||
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);
|
||||
@@ -74,7 +78,7 @@ export function StorageSettings() {
|
||||
async function refresh() {
|
||||
const [plugin, other] = await Promise.all([
|
||||
api<Location>('storage.plugin.status'),
|
||||
api<{apk: Location; contexts: Location; interrupted?: {kind: Kind; target: string} | null}>('apk.storage.status'),
|
||||
api<{apk: Location; contexts: Location; volume: Volume; interrupted?: {kind: Kind; target: string} | null}>('apk.storage.status'),
|
||||
]);
|
||||
const next: Places = {
|
||||
plugin,
|
||||
@@ -82,6 +86,7 @@ export function StorageSettings() {
|
||||
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,
|
||||
@@ -136,24 +141,68 @@ export function StorageSettings() {
|
||||
{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-heading"><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-location"><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>}
|
||||
<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}`}))}/>}
|
||||
|
||||
Reference in new issue
Block a user