mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-17 19:01:26 +02:00
Handle restricted overlay directories in storage settings and migration
This commit is contained in:
1 parent
6cb052f732
commit
c647aa28a0
3 files changed
+180
-10
No files matched your search
+44
-4
@@ -1002,15 +1002,21 @@ pub fn storage_status(home: &Path) -> Result<Value> {
|
||||
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;
|
||||
for id in &db.owned_contexts {
|
||||
if safe(id) { context_bytes = context_bytes.saturating_add(crate::storage_location::bytes(&contexts.join(id))?); }
|
||||
if safe(id) {
|
||||
let (size, incomplete) = crate::storage_location::display_bytes(&contexts.join(id))?;
|
||||
context_bytes = context_bytes.saturating_add(size);
|
||||
contexts_incomplete |= incomplete;
|
||||
}
|
||||
}
|
||||
let (apk_bytes, apk_incomplete) = crate::storage_location::display_bytes(&apk)?;
|
||||
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":crate::storage_location::bytes(&apk)?},"contexts":{"path":contexts,"bytes":context_bytes},"interrupted":if committed {None} else {journal}}))
|
||||
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}}))
|
||||
}
|
||||
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"), "无效存储类型");
|
||||
@@ -1075,11 +1081,11 @@ pub fn migrate_storage(home: &Path, kind: &str, target: &Path, cancel: Cancellat
|
||||
progress(json!({"step":"cleaning"}));
|
||||
let mut warnings = Vec::new();
|
||||
if kind == "apk" {
|
||||
if let Err(error) = fs::remove_dir_all(&source) { warnings.push(error.to_string()); }
|
||||
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) = fs::remove_dir_all(source.join(id)) { warnings.push(error.to_string()); }
|
||||
if let Err(error) = crate::storage_location::remove_tree(&source.join(id)) { warnings.push(error.to_string()); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1101,10 +1107,18 @@ mod storage_migration_tests {
|
||||
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();
|
||||
@@ -1115,6 +1129,8 @@ mod storage_migration_tests {
|
||||
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();
|
||||
@@ -1126,6 +1142,8 @@ mod storage_migration_tests {
|
||||
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() {
|
||||
@@ -1145,6 +1163,28 @@ mod storage_migration_tests {
|
||||
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![] };
|
||||
|
||||
+134
-4
@@ -4,8 +4,12 @@ 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};
|
||||
|
||||
pub fn bytes(path: &Path) -> Result<u64> {
|
||||
if !path.exists() { return Ok(0); }
|
||||
let root = if fs::symlink_metadata(path)?.file_type().is_symlink() { path.canonicalize()? } else { path.to_path_buf() };
|
||||
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)?;
|
||||
@@ -19,6 +23,44 @@ 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`.
|
||||
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<()> {
|
||||
@@ -46,7 +88,10 @@ fn inventory(path: &Path) -> Result<BTreeMap<PathBuf, String>> {
|
||||
Ok(files)
|
||||
}
|
||||
pub fn verified_equal(source: &Path, target: &Path) -> Result<bool> {
|
||||
Ok(source.is_dir() && target.is_dir() && inventory(source)? == inventory(target)?)
|
||||
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);
|
||||
@@ -120,8 +165,55 @@ fn prepare_space(parent: &Path, needed: u64) -> Result<()> {
|
||||
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>()));
|
||||
@@ -137,6 +229,7 @@ pub fn copy_verified(source: &Path, target: &Path, mut progress: impl FnMut(&str
|
||||
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)?;
|
||||
@@ -151,7 +244,12 @@ pub fn copy_selected(source: &Path, target: &Path, names: &[String], follow_root
|
||||
let target = validate_target(source, target, false)?;
|
||||
let parent = target.parent().unwrap();
|
||||
let mut needed = 0u64;
|
||||
for name in names { needed = needed.saturating_add(bytes(&selected_source(source, name, follow_root_links)?)?); }
|
||||
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)?;
|
||||
@@ -170,6 +268,7 @@ pub fn copy_selected(source: &Path, target: &Path, names: &[String], follow_root
|
||||
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)?;
|
||||
@@ -211,9 +310,16 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
@@ -234,4 +340,28 @@ mod tests {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {t} from './i18n';
|
||||
import './storage-settings.css';
|
||||
|
||||
type Kind='plugin'|'apk'|'contexts';
|
||||
type Location={path:string;bytes:number;migrating?:boolean;available?:boolean;interrupted?:string|null};
|
||||
type Location={path:string;bytes:number;sizeIncomplete?:boolean;migrating?:boolean;available?:boolean;interrupted?:string|null};
|
||||
type Places={plugin:Location;apk:Location;contexts:Location};
|
||||
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}[]};
|
||||
@@ -30,5 +30,5 @@ export function StorageSettings(){
|
||||
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);setNotice(value.result?.cleanupWarnings?.length?t('迁移完成;旧目录清理未完成,请检查原位置。'):t('迁移完成'));setPaths(v=>({...v,[job.kind]:value.result?.path??v[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=setInterval(()=>void poll(),500);return()=>{alive=false;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);}}
|
||||
const descriptions:Record<string,string>={queued:'排队中',loading:'准备中',stopping:'停止相关程序',copying:'复制数据',verifying:'核对文件',switching:'切换目录',restarting:'恢复运行',cleaning:'清理旧目录'};
|
||||
return <div className="storage-settings"><div className="list-heading"><div><h1>{t('存储')}</h1><p>{t('分别管理插件、APK 文件和安卓容器的数据位置。')}</p></div></div><p className="storage-intro">{t('迁移会暂时停止相关程序;完成前原位置仍可用于恢复。目标目录须为空,并支持 Linux 文件权限。')}</p>{error&&<p className="error" role="alert">{error}</p>}{notice&&<p className="storage-notice" role="status">{notice}</p>}{places&&(['plugin','apk','contexts'] as const).map(kind=>{const place=places[kind],busy=!!job||!!active;return <section className="settings-card storage-card" key={kind}><div className="storage-card-heading"><div className="storage-card-icon"><IconDeviceFloppy size={24} stroke={1.6}/></div><div><h2>{t(labels[kind].title)}</h2><p>{t(labels[kind].description)}</p></div><strong>{size(place.bytes)}</strong></div><div className="storage-current"><span>{t('当前位置')}</span><code title={place.path}>{place.path}</code></div>{place.available===false&&<p className="storage-interrupted" role="status">{t('当前存储目录不可用,请先连接存储设备。')}</p>}{place.interrupted&&<p className="storage-interrupted" role="status">{t('上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。原副本不会自动删除:')}<code>{place.interrupted}</code></p>}<label className="storage-target"><span>{t('目标目录')}</span><div><input aria-label={`${t(labels[kind].title)} ${t('目标目录')}`} value={paths[kind]} disabled={busy} onChange={e=>setPaths(v=>({...v,[kind]:e.target.value}))} spellCheck={false}/><button disabled={busy} onClick={()=>setPicker(kind)}><IconFolderOpen size={18}/>{t('浏览')}</button></div></label><div className="storage-actions"><button className="primary" disabled={busy||place.available===false||!paths[kind]||paths[kind]===place.path} onClick={()=>void migrate(kind)}>{active===kind?<><IconLoader2 size={18}/>{t(descriptions[step]??step)}</>:t('迁移到此目录')}</button></div></section>;})}{picker&&<FolderBrowser close={()=>setPicker(null)} choose={parent=>setPaths(v=>({...v,[picker]:parent.replace(/\/$/,'')+'/'+labels[picker].folder.replace(/ /g,'-')}))}/>}</div>;
|
||||
return <div className="storage-settings"><div className="list-heading"><div><h1>{t('存储')}</h1><p>{t('分别管理插件、APK 文件和安卓容器的数据位置。')}</p></div></div><p className="storage-intro">{t('迁移会暂时停止相关程序;完成前原位置仍可用于恢复。目标目录须为空,并支持 Linux 文件权限。')}</p>{error&&<p className="error" role="alert">{error}</p>}{notice&&<p className="storage-notice" role="status">{notice}</p>}{places&&(['plugin','apk','contexts'] as const).map(kind=>{const place=places[kind],busy=!!job||!!active;return <section className="settings-card storage-card" key={kind}><div className="storage-card-heading"><div className="storage-card-icon"><IconDeviceFloppy size={24} stroke={1.6}/></div><div><h2>{t(labels[kind].title)}</h2><p>{t(labels[kind].description)}</p></div><strong title={place.sizeIncomplete?t('部分目录无法读取,显示可统计的最小占用'):undefined}>{place.sizeIncomplete?'≥ ':''}{size(place.bytes)}</strong></div><div className="storage-current"><span>{t('当前位置')}</span><code title={place.path}>{place.path}</code></div>{place.available===false&&<p className="storage-interrupted" role="status">{t('当前存储目录不可用,请先连接存储设备。')}</p>}{place.interrupted&&<p className="storage-interrupted" role="status">{t('上次迁移未完成,原目录仍在使用。可重试原目标或选择新的空目录。原副本不会自动删除:')}<code>{place.interrupted}</code></p>}<label className="storage-target"><span>{t('目标目录')}</span><div><input aria-label={`${t(labels[kind].title)} ${t('目标目录')}`} value={paths[kind]} disabled={busy} onChange={e=>setPaths(v=>({...v,[kind]:e.target.value}))} spellCheck={false}/><button disabled={busy} onClick={()=>setPicker(kind)}><IconFolderOpen size={18}/>{t('浏览')}</button></div></label><div className="storage-actions"><button className="primary" disabled={busy||place.available===false||!paths[kind]||paths[kind]===place.path} onClick={()=>void migrate(kind)}>{active===kind?<><IconLoader2 size={18}/>{t(descriptions[step]??step)}</>:t('迁移到此目录')}</button></div></section>;})}{picker&&<FolderBrowser close={()=>setPicker(null)} choose={parent=>setPaths(v=>({...v,[picker]:parent.replace(/\/$/,'')+'/'+labels[picker].folder.replace(/ /g,'-')}))}/>}</div>;
|
||||
}
|
||||
Reference in new issue
Block a user