mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-29 04:02:47 +01:00
Make retained-container APK uninstall readiness and cleanup retry-safe
This commit is contained in:
1 parent
accae9cfbf
commit
b22d7e9fc3
11 files changed
+410
-65
No files matched your search
@@ -163,10 +163,14 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
|
||||
|
||||
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
|
||||
|
||||
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
|
||||
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,确认 Android 卸载成功后再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
|
||||
|
||||
Container list headings show installed application names; the data path identifies the container. An APK update stops its container before backing up data. If stopping fails or a native Steam launch still owns it, the installation dialog offers **Close container** with confirmation. Closing interrupts running apps and retains their saves. The reviewed APK remains available so installation can be retried after closing.
|
||||
|
||||
### Steam entry retention
|
||||
|
||||
**APK → Settings → Keep installed APKs in Steam** defaults to on. Turning it off removes idle Framely-owned entries and disables automatic registration. Launching an app whose **Launch through Steam** option is enabled registers its entry on demand; after the native launch process and container finish, the entry and its temporary Devkit copy are removed. Installed APKs, containers, saves and downloaded game data are retained. Running entries are protected. Failed or interrupted starts are cleaned up after a startup grace period, with retries when Steam is unavailable. Turning retention back on resumes automatic registration.
|
||||
|
||||
### Uninstalling while keeping the container
|
||||
|
||||
Keeping application data uses Android PackageManager readiness instead of requiring user unlock or external storage mounting. Deleting data still waits for those conditions. Framely boots a stopped container without showing its desktop and repairs only the selected APK registration if needed. It reports startup, PackageManager, unlock, storage and container-exit failures separately. Steam entries are removed after Android confirms successful uninstall; Android success is saved first so a Steam cleanup retry does not uninstall again. Failed startup or uninstall preserves the application record, Steam binding and application data. The error dialog offers Retry and Export logs. A container that cannot boot still requires diagnosis; Framely does not silently delete it or edit Android package databases.
|
||||
@@ -137,10 +137,14 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
|
||||
|
||||
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
|
||||
|
||||
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
|
||||
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,确认 Android 卸载成功后再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
|
||||
|
||||
容器列表标题显示已安装应用名称,数据路径用于识别容器。更新 APK 会先停止容器再备份数据。如果停止失败,或原生 Steam 启动进程尚未释放容器,安装确认中提供 **关闭容器** 按钮并要求确认。关闭会中断运行中的应用,保留存档;已检查的 APK 会保留,可关闭后重试安装。
|
||||
|
||||
### Steam 入口保留
|
||||
|
||||
**APK → 设置 → 保留安装的 APK 到 Steam** 默认开启。关闭后清理空闲的 Framely 自有入口,不再自动注册。应用启用 **通过 Steam 启动** 时,会在启动时临时注册入口;原生启动进程和容器退出后删除入口及临时 Devkit 副本。已安装 APK、容器、存档和下载的游戏数据都会保留,运行中的入口不会删除。失败或中断的启动在启动宽限期后清理;Steam 不可用时稍后重试。重新开启保留设置会恢复自动注册。
|
||||
|
||||
### 卸载并保留容器
|
||||
|
||||
保留应用数据的卸载使用 Android 包管理器就绪检查,不再强制等待用户解锁或外部存储挂载。删除数据仍等待这些条件。Framely 在后台启动已停止的容器,不显示容器桌面;需要时只修复目标 APK 的登记。启动、包管理器、解锁、存储和容器退出分别报错。Android 确认卸载成功后才移除 Steam 入口,先保存 Android 卸载结果,重试 Steam 清理不会重复卸载。启动或卸载失败保留应用记录、Steam 绑定和应用数据。错误弹窗提供重试与导出日志。无法启动的容器仍需诊断,不会自动删除容器或直接修改 Android 包数据库。
|
||||
+351
-48
@@ -1286,17 +1286,64 @@ fn start_container(
|
||||
) -> Result<()> {
|
||||
start_oriented_container(home, c, show, direct, None, None, None, log)
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum AndroidReadiness {
|
||||
Full,
|
||||
PackageManager,
|
||||
RemovalWithData,
|
||||
}
|
||||
const ANDROID_READY_PROBE: &str = r#"
|
||||
[ "$(getprop sys.boot_completed)" = 1 ] &&
|
||||
[ "$(am get-started-user-state 0)" = RUNNING_UNLOCKED ] &&
|
||||
[ -d /storage/emulated/0 ] &&
|
||||
dumpsys mount | grep -q 'mountUserId=0 state=MOUNTED' &&
|
||||
pm path android | grep -q '^package:' &&
|
||||
echo FRAMELY_ANDROID_READY
|
||||
boot=$(getprop sys.boot_completed)
|
||||
echo FRAMELY_BOOT=$boot
|
||||
pm_ready=0
|
||||
pm path android | grep -q '^package:' && pm_ready=1
|
||||
echo FRAMELY_PM=$pm_ready
|
||||
if [ "$1" = package ]; then
|
||||
[ "$boot" = 1 ] && [ "$pm_ready" = 1 ] && echo FRAMELY_ANDROID_READY
|
||||
exit 0
|
||||
fi
|
||||
user=$(am get-started-user-state 0)
|
||||
echo FRAMELY_USER=$user
|
||||
storage=0
|
||||
if [ -d /storage/emulated/0 ] && sm list-volumes all | grep -Eq '^emulated(;0)? mounted( |$)'; then storage=1; fi
|
||||
echo FRAMELY_STORAGE=$storage
|
||||
[ "$boot" = 1 ] && [ "$pm_ready" = 1 ] && [ "$user" = RUNNING_UNLOCKED ] && [ "$storage" = 1 ] && echo FRAMELY_ANDROID_READY
|
||||
exit 0
|
||||
"#;
|
||||
fn wait_android_ready(c: &Container) -> Result<()> {
|
||||
fn android_readiness_failure(snapshot: &str, requirement: AndroidReadiness) -> &'static str {
|
||||
if !snapshot.lines().any(|s| s.starts_with("FRAMELY_BOOT=")) {
|
||||
"Android readiness probe did not respond; export logs and retry."
|
||||
} else if !snapshot.lines().any(|s| s == "FRAMELY_BOOT=1") {
|
||||
"Android startup timed out; export logs and retry."
|
||||
} else if !snapshot.lines().any(|s| s == "FRAMELY_PM=1") {
|
||||
"Android PackageManager did not become ready; export logs and retry."
|
||||
} else if requirement != AndroidReadiness::PackageManager
|
||||
&& !snapshot
|
||||
.lines()
|
||||
.any(|s| s == "FRAMELY_USER=RUNNING_UNLOCKED")
|
||||
{
|
||||
"Android user did not unlock; export logs and retry."
|
||||
} else {
|
||||
"Android external storage did not mount; export logs and retry."
|
||||
}
|
||||
}
|
||||
fn wait_android_ready_for(
|
||||
c: &Container,
|
||||
requirement: AndroidReadiness,
|
||||
log: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
wait_android_ready_until(c, requirement, log, Duration::from_secs(60))
|
||||
}
|
||||
fn wait_android_ready_until(
|
||||
c: &Container,
|
||||
requirement: AndroidReadiness,
|
||||
log: Option<&Path>,
|
||||
timeout: Duration,
|
||||
) -> Result<()> {
|
||||
let began = Instant::now();
|
||||
while began.elapsed() < Duration::from_secs(60) {
|
||||
let mut last = String::new();
|
||||
let mut seen_running = c.running;
|
||||
while began.elapsed() < timeout {
|
||||
let mut command = crate::process::tool("podman");
|
||||
command.args([
|
||||
"exec",
|
||||
@@ -1304,15 +1351,64 @@ fn wait_android_ready(c: &Container) -> Result<()> {
|
||||
"sh",
|
||||
"-c",
|
||||
ANDROID_READY_PROBE,
|
||||
"framely-android-ready",
|
||||
if requirement == AndroidReadiness::PackageManager {
|
||||
"package"
|
||||
} else {
|
||||
"full"
|
||||
},
|
||||
]);
|
||||
if output(command, Duration::from_secs(3), None)
|
||||
.is_ok_and(|s| s.lines().any(|line| line.trim() == "FRAMELY_ANDROID_READY"))
|
||||
let snapshot = output(command, Duration::from_secs(3), log)
|
||||
.unwrap_or_else(|e| format!("Android readiness probe failed: {e}"));
|
||||
if snapshot != last {
|
||||
if let Some(log) = log {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.mode(0o600)
|
||||
.open(log)?;
|
||||
writeln!(file, "Android readiness ({requirement:?}):\n{snapshot}")?;
|
||||
}
|
||||
last = snapshot;
|
||||
}
|
||||
if last
|
||||
.lines()
|
||||
.any(|line| line.trim() == "FRAMELY_ANDROID_READY")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let mut inspect = crate::process::tool("podman");
|
||||
inspect.args([
|
||||
"inspect",
|
||||
"--format",
|
||||
"{{.State.Running}}|{{.State.Pid}}|{{.State.Status}}|{{.State.ExitCode}}",
|
||||
&format!("lepton-{}", c.name),
|
||||
]);
|
||||
if let Ok(state) = output(inspect, Duration::from_secs(2), None) {
|
||||
let fields: Vec<_> = state.trim().split('|').collect();
|
||||
let alive = fields.first() == Some(&"true")
|
||||
&& fields.get(1).is_some_and(|pid| runtime_pid_alive(pid));
|
||||
if alive {
|
||||
seen_running = true;
|
||||
} else if seen_running || began.elapsed() >= Duration::from_secs(30) {
|
||||
if let Some(log) = log {
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.mode(0o600)
|
||||
.open(log)?;
|
||||
writeln!(
|
||||
file,
|
||||
"Container stopped during Android readiness: {}",
|
||||
state.trim()
|
||||
)?;
|
||||
}
|
||||
bail!("Android container stopped before becoming ready; export logs and retry.");
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
bail!("Android boot, user unlock or external storage did not become ready; inspect the container log")
|
||||
bail!(android_readiness_failure(&last, requirement))
|
||||
}
|
||||
fn start_oriented_container(
|
||||
home: &Path,
|
||||
@@ -1323,14 +1419,48 @@ fn start_oriented_container(
|
||||
gamepad: Option<&crate::gamepad::Mount>,
|
||||
steam_app_id: Option<u32>,
|
||||
log: &Path,
|
||||
) -> Result<()> {
|
||||
start_oriented_container_for(
|
||||
home,
|
||||
c,
|
||||
show,
|
||||
direct,
|
||||
orientation,
|
||||
gamepad,
|
||||
steam_app_id,
|
||||
AndroidReadiness::Full,
|
||||
log,
|
||||
)
|
||||
}
|
||||
fn start_oriented_container_for(
|
||||
home: &Path,
|
||||
c: &Container,
|
||||
show: Option<bool>,
|
||||
direct: bool,
|
||||
orientation: Option<&str>,
|
||||
gamepad: Option<&crate::gamepad::Mount>,
|
||||
steam_app_id: Option<u32>,
|
||||
readiness: AndroidReadiness,
|
||||
log: &Path,
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
orientation.is_none_or(|s| matches!(s, "auto" | "portrait" | "landscape")),
|
||||
"Invalid window orientation"
|
||||
);
|
||||
if running(&c.name) {
|
||||
wait_android_ready(c)?;
|
||||
return restore_package_mount(home, c, log);
|
||||
wait_android_ready_for(
|
||||
&Container {
|
||||
running: true,
|
||||
..c.clone()
|
||||
},
|
||||
readiness,
|
||||
Some(log),
|
||||
)?;
|
||||
return if readiness == AndroidReadiness::Full {
|
||||
restore_package_mount(home, c, log)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
}
|
||||
let lock_dir = root(home).join("steam");
|
||||
fs::create_dir_all(&lock_dir)?;
|
||||
@@ -1436,8 +1566,19 @@ fn start_oriented_container(
|
||||
});
|
||||
// PackageManager can respond before the user's external storage is ready.
|
||||
// Lepton's own app launcher also waits for boot and media mounting.
|
||||
wait_android_ready(c)?;
|
||||
restore_package_mount(home, c, log)
|
||||
wait_android_ready_for(
|
||||
&Container {
|
||||
running: false,
|
||||
..c.clone()
|
||||
},
|
||||
readiness,
|
||||
Some(log),
|
||||
)?;
|
||||
if readiness == AndroidReadiness::Full {
|
||||
restore_package_mount(home, c, log)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn wrapper_active(name: &str) -> bool {
|
||||
fs::read_dir("/proc")
|
||||
@@ -2656,10 +2797,19 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
|
||||
Ok(json!({"containerDeleted":true}))
|
||||
});
|
||||
}
|
||||
if kind == "purge" && !a.installed {
|
||||
if !a.installed && (kind == "purge" || (kind == "uninstall" && p["purge"] == true))
|
||||
{
|
||||
ensure!(p["approve"] == true, "Confirm APK removal");
|
||||
return cancel.commit(|| {
|
||||
progress(json!({"cancellable":false}));
|
||||
if let Some(rec) = db.records.get_mut(id) {
|
||||
steam_shortcuts::unregister(home, rec)
|
||||
.context("APK was uninstalled, but Steam entry cleanup failed; retry to finish cleanup")?;
|
||||
rec.steam_launch = false;
|
||||
rec.steam_binding = None;
|
||||
rec.steam_app_id = None;
|
||||
}
|
||||
save(home, &db)?;
|
||||
Ok(json!({"freedBytes":purge_retained(home,&mut db,id,&log)?}))
|
||||
});
|
||||
}
|
||||
@@ -2817,41 +2967,48 @@ progress(json!({"phase":"starting"}));start(home,&c,Some(false),&log)?;
|
||||
if db.records.get(id).and_then(native::runtime_name).is_some() {
|
||||
stop(&c, &log)?;
|
||||
}
|
||||
if let Some(rec) = db.records.get_mut(id) {
|
||||
steam_shortcuts::unregister(home, rec)?;
|
||||
rec.steam_launch = false;
|
||||
rec.steam_binding = None;
|
||||
rec.steam_app_id = None;
|
||||
save(home, &db)?;
|
||||
}
|
||||
progress(json!({"phase":"uninstalling"}));
|
||||
start(home, &c, a.show_window, &log)?;
|
||||
progress(json!({"phase":"starting"}));
|
||||
if a.installed {
|
||||
if let Some((_, apk)) = installed(home, &c)?
|
||||
.into_iter()
|
||||
.find(|(m, _)| m.package == a.metadata.package)
|
||||
{
|
||||
let dest = r.join("apks").join(hash(id));
|
||||
if apk.is_file() {
|
||||
store_apk(&apk, &dest)?;
|
||||
}
|
||||
let dest = r.join("apks").join(hash(id));
|
||||
if let Some((_, apk)) = installed(home, &Container { running:false, ..c.clone() })?
|
||||
.into_iter().find(|(m, _)| m.package == a.metadata.package) {
|
||||
if apk.is_file() { store_apk(&apk, &dest)?; }
|
||||
}
|
||||
let readiness = if kind == "uninstall" && p["purge"] != true {
|
||||
AndroidReadiness::PackageManager
|
||||
} else { AndroidReadiness::RemovalWithData };
|
||||
start_oriented_container_for(home, &c, Some(false), true, None, None, None, readiness, &log)?;
|
||||
progress(json!({"phase":"uninstalling"}));
|
||||
if !live_installed(&c, &a.metadata.package, &log)? {
|
||||
let cached = dest.join("base.apk");
|
||||
ensure!(cached.is_file(), "Original APK is required to recover package registration before uninstall");
|
||||
install_package(&c, &cached, &log)?;
|
||||
}
|
||||
let mut args = vec!["exec", "", "pm", "uninstall"];
|
||||
let name = format!("lepton-{}", c.name);
|
||||
args[1] = &name;
|
||||
if kind == "uninstall" && p["purge"] != true {
|
||||
args.push("-k");
|
||||
}
|
||||
args.push(&a.metadata.package);
|
||||
let s = podman(&args, Some(&log))?;
|
||||
ensure!(
|
||||
s.lines().any(|s| s.trim() == "Success"),
|
||||
"Uninstall failed: {s}"
|
||||
);
|
||||
}
|
||||
let mut args = vec!["exec", "", "pm", "uninstall"];
|
||||
let name = format!("lepton-{}", c.name);
|
||||
args[1] = &name;
|
||||
if kind == "uninstall" && p["purge"] != true {
|
||||
args.push("-k");
|
||||
}
|
||||
args.push(&a.metadata.package);
|
||||
let s = podman(&args, Some(&log))?;
|
||||
ensure!(
|
||||
s.lines().any(|s| s.trim() == "Success"),
|
||||
"Uninstall failed: {s}"
|
||||
);
|
||||
let rec = db.records.get_mut(id).unwrap();
|
||||
rec.removed = true;
|
||||
rec.pending = None;
|
||||
// Persist Android success before touching Steam. If cleanup
|
||||
// fails, retry only cleanup rather than uninstalling twice.
|
||||
save(home, &db)?;
|
||||
let rec = db.records.get_mut(id).unwrap();
|
||||
steam_shortcuts::unregister(home, rec)
|
||||
.context("APK was uninstalled, but Steam entry cleanup failed; retry to finish cleanup")?;
|
||||
rec.steam_launch = false;
|
||||
rec.steam_binding = None;
|
||||
rec.steam_app_id = None;
|
||||
if kind == "purge" || p["purge"] == true {
|
||||
db.records.remove(id);
|
||||
let path = r.join("apks").join(hash(id));
|
||||
@@ -3701,7 +3858,7 @@ case "$1" in
|
||||
list) if [ -f '{apk}' ] && [ ! -f '{broken}' ] && [ ! -f '{removed}' ]; then echo package:com.example.app; fi;;
|
||||
path) if [ "$5" = android ] || [ -f '{apk}' ]; then echo package:/data/app/xyz/com.example.app/base.apk; fi;;
|
||||
install) if [ -f '{fail}' ]; then echo 'Failure [INSTALL_FAILED_UPDATE_INCOMPATIBLE]'; else /bin/cp -f '{incoming}' '{apk}'; /bin/rm -f '{broken}' '{removed}'; echo '<package-restrictions><pkg name="com.example.app" installed="true"/></package-restrictions>' > '{restrictions}'; echo Success; fi;;
|
||||
uninstall) touch '{removed}'; /bin/rm -f '{apk}'; echo '<package-restrictions><pkg name="com.example.app" installed="false"/></package-restrictions>' > '{restrictions}'; if [ "$5" != -k ]; then /bin/rm -rf '{data}'; fi; echo Success;;
|
||||
uninstall) if [ -f '{uninstall_fail}' ]; then echo 'Failure [DELETE_FAILED_INTERNAL_ERROR]'; exit 0; fi; touch '{removed}'; /bin/rm -f '{apk}'; echo '<package-restrictions><pkg name="com.example.app" installed="false"/></package-restrictions>' > '{restrictions}'; if [ "$5" != -k ]; then /bin/rm -rf '{data}'; fi; echo Success;;
|
||||
clear) echo Success;;
|
||||
esac
|
||||
elif [ "$3" = sh ]; then
|
||||
@@ -3727,7 +3884,13 @@ case "$1" in
|
||||
else echo FRAMELY_APP_PRESENT; fi
|
||||
else
|
||||
if [ -f '{not_ready}' ]; then /bin/rm -f '{not_ready}'; exit 1; fi
|
||||
echo FRAMELY_ANDROID_READY;
|
||||
if [ -f '{boot_crash}' ]; then
|
||||
echo FRAMELY_BOOT=0; echo FRAMELY_PM=0;
|
||||
if [ -f '{probe_seen}' ]; then echo false > '{state}'; else touch '{probe_seen}'; fi
|
||||
elif [ -f '{blocked_storage}' ]; then
|
||||
echo FRAMELY_BOOT=1; echo FRAMELY_PM=1; echo FRAMELY_USER=RUNNING_LOCKED; echo FRAMELY_STORAGE=0;
|
||||
if [ "$7" = package ]; then echo FRAMELY_ANDROID_READY; fi
|
||||
else echo FRAMELY_ANDROID_READY; fi;
|
||||
fi;
|
||||
elif [ "$3" = pidof ]; then
|
||||
if [ -f '{dead}' ] || {{ [ "$4" = com.example.app ] && [ -f '{custom}' ]; }} || {{ [ "$4" = surfaceflinger ] && [ -f '{headless}' ]; }}; then exit 1; else echo 1050; fi;
|
||||
@@ -3752,6 +3915,10 @@ esac
|
||||
state = state.display(),
|
||||
commands = dir.path().join("commands").display(),
|
||||
not_ready = dir.path().join("boot-not-ready").display(),
|
||||
uninstall_fail = dir.path().join("uninstall-fail").display(),
|
||||
boot_crash = dir.path().join("boot-crash").display(),
|
||||
probe_seen = dir.path().join("probe-seen").display(),
|
||||
blocked_storage = dir.path().join("blocked-storage").display(),
|
||||
launch_fail = dir.path().join("launch-fail").display(),
|
||||
dead = dir.path().join("dead-process").display(),
|
||||
custom = dir.path().join("custom-process").display(),
|
||||
@@ -4401,7 +4568,7 @@ setup_props
|
||||
start(&f.home, &c, None, &f.dir.path().join("launch.log")).unwrap();
|
||||
let commands = fs::read_to_string(f.dir.path().join("commands")).unwrap();
|
||||
let probes: Vec<_> = commands
|
||||
.match_indices("echo FRAMELY_ANDROID_READY")
|
||||
.match_indices("framely-android-ready full")
|
||||
.collect();
|
||||
assert_eq!(probes.len(), 2);
|
||||
let repair = commands
|
||||
@@ -5221,6 +5388,142 @@ esac
|
||||
assert!(!f.save_file().exists());
|
||||
}
|
||||
#[test]
|
||||
fn retained_uninstall_does_not_wait_for_unlock_or_external_storage() {
|
||||
let f = Fixture::new();
|
||||
list(&f.home).unwrap();
|
||||
fs::write(f.dir.path().join("blocked-storage"), "").unwrap();
|
||||
let (_, c) = app(&f.home, &load(&f.home).unwrap(), "test/com.example.app").unwrap();
|
||||
let error =
|
||||
wait_android_ready_until(&c, AndroidReadiness::Full, None, Duration::from_millis(100))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("user did not unlock"), "{error}");
|
||||
f.operation(
|
||||
"uninstall",
|
||||
json!({"app":"test/com.example.app","approve":true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(f.save_file().exists());
|
||||
assert!(load(&f.home).unwrap().records["test/com.example.app"].removed);
|
||||
}
|
||||
#[test]
|
||||
fn failed_android_start_or_uninstall_preserves_steam_bindings_and_saves() {
|
||||
let f = Fixture::new();
|
||||
list(&f.home).unwrap();
|
||||
let id = "test/com.example.app";
|
||||
let mut db = load(&f.home).unwrap();
|
||||
db.records.get_mut(id).unwrap().steam_binding = Some(steam_shortcuts::Binding {
|
||||
game_id: steam_shortcuts::game_id(id),
|
||||
native: false,
|
||||
revision: String::new(),
|
||||
});
|
||||
save(&f.home, &db).unwrap();
|
||||
let before = fs::read(root(&f.home).join("state.json")).unwrap();
|
||||
fs::write(f.dir.path().join("boot-crash"), "").unwrap();
|
||||
let error = f
|
||||
.operation("uninstall", json!({"app":id,"approve":true}))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("container stopped"), "{error}");
|
||||
assert_eq!(before, fs::read(root(&f.home).join("state.json")).unwrap());
|
||||
assert!(f.save_file().exists());
|
||||
assert!(!f.dir.path().join("removed").exists());
|
||||
fs::remove_file(f.dir.path().join("boot-crash")).unwrap();
|
||||
fs::write(f.dir.path().join("running"), "true").unwrap();
|
||||
fs::write(f.dir.path().join("uninstall-fail"), "").unwrap();
|
||||
let error = f
|
||||
.operation("uninstall", json!({"app":id,"approve":true}))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("DELETE_FAILED_INTERNAL_ERROR"),
|
||||
"{error}"
|
||||
);
|
||||
assert_eq!(before, fs::read(root(&f.home).join("state.json")).unwrap());
|
||||
assert!(f.save_file().exists());
|
||||
}
|
||||
#[test]
|
||||
fn steam_cleanup_failure_records_android_success_and_retry_does_not_uninstall_twice() {
|
||||
let f = Fixture::new();
|
||||
list(&f.home).unwrap();
|
||||
let id = "test/com.example.app";
|
||||
let mut db = load(&f.home).unwrap();
|
||||
db.records.get_mut(id).unwrap().steam_binding = Some(steam_shortcuts::Binding {
|
||||
game_id: "invalid-binding".into(),
|
||||
native: false,
|
||||
revision: String::new(),
|
||||
});
|
||||
save(&f.home, &db).unwrap();
|
||||
let error = f
|
||||
.operation("uninstall", json!({"app":id,"approve":true}))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("APK was uninstalled"), "{error}");
|
||||
let mut db = load(&f.home).unwrap();
|
||||
assert!(db.records[id].removed);
|
||||
assert!(db.records[id].steam_binding.is_some());
|
||||
assert!(f.save_file().exists());
|
||||
let commands = f.dir.path().join("commands");
|
||||
let count = fs::read_to_string(&commands)
|
||||
.unwrap()
|
||||
.matches("pm uninstall")
|
||||
.count();
|
||||
db.records.get_mut(id).unwrap().steam_binding = None;
|
||||
save(&f.home, &db).unwrap();
|
||||
f.operation("uninstall", json!({"app":id,"approve":true}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
count,
|
||||
fs::read_to_string(&commands)
|
||||
.unwrap()
|
||||
.matches("pm uninstall")
|
||||
.count()
|
||||
);
|
||||
assert!(f.save_file().exists());
|
||||
}
|
||||
#[test]
|
||||
fn android_ready_probe_uses_operation_specific_checks_and_accepts_volume_output() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let run = |mode: &str, user: &str, volume: &str| {
|
||||
let mocks = format!("getprop() {{ echo 1; }}; pm() {{ echo package:/system/framework/framework-res.apk; }}; am() {{ echo {user}; }}; sm() {{ echo '{volume}'; }};\n");
|
||||
let probe = ANDROID_READY_PROBE
|
||||
.replace("/storage/emulated/0", &dir.path().display().to_string());
|
||||
let mut command = Command::new("/bin/sh");
|
||||
command.args(["-c", &(mocks + &probe), "probe", mode]);
|
||||
output(command, Duration::from_secs(2), None).unwrap()
|
||||
};
|
||||
assert!(
|
||||
run("package", "RUNNING_LOCKED", "emulated;0 unmounted null")
|
||||
.contains("FRAMELY_ANDROID_READY")
|
||||
);
|
||||
assert!(!run("full", "RUNNING_LOCKED", "emulated;0 mounted null")
|
||||
.contains("FRAMELY_ANDROID_READY"));
|
||||
assert!(
|
||||
!run("full", "RUNNING_UNLOCKED", "emulated;0 unmounted null")
|
||||
.contains("FRAMELY_ANDROID_READY")
|
||||
);
|
||||
assert!(run("full", "RUNNING_UNLOCKED", "emulated;0 mounted null")
|
||||
.contains("FRAMELY_ANDROID_READY"));
|
||||
assert!(run("full", "RUNNING_UNLOCKED", "emulated mounted null")
|
||||
.contains("FRAMELY_ANDROID_READY"));
|
||||
}
|
||||
#[test]
|
||||
fn android_ready_errors_identify_the_blocked_stage() {
|
||||
assert!(
|
||||
android_readiness_failure("probe timed out", AndroidReadiness::Full)
|
||||
.contains("did not respond")
|
||||
);
|
||||
assert!(
|
||||
android_readiness_failure("FRAMELY_BOOT=0", AndroidReadiness::Full).contains("startup")
|
||||
);
|
||||
assert!(android_readiness_failure(
|
||||
"FRAMELY_BOOT=1\nFRAMELY_PM=0",
|
||||
AndroidReadiness::PackageManager
|
||||
)
|
||||
.contains("PackageManager"));
|
||||
assert!(android_readiness_failure(
|
||||
"FRAMELY_BOOT=1\nFRAMELY_PM=1\nFRAMELY_USER=RUNNING_UNLOCKED\nFRAMELY_STORAGE=0",
|
||||
AndroidReadiness::Full
|
||||
)
|
||||
.contains("external storage"));
|
||||
}
|
||||
#[test]
|
||||
fn updating_uninstalling_retaining_and_batch_cleanup_are_scoped() {
|
||||
let f = Fixture::new();
|
||||
list(&f.home).unwrap();
|
||||
|
||||
@@ -5,17 +5,18 @@ const wait=(ms=80)=>new Promise(r=>setTimeout(r,ms));const until=async(f:()=>unk
|
||||
const meta=(pkg:string,name:string,launch=true)=>({package:pkg,name,version:'1.2.3',versionCode:123,minSdk:24,activities:launch?[`${pkg}.Main`]:[],declaredActivities:[`${pkg}.Main`],abis:['arm64-v8a'],vr:false});
|
||||
const apps:any[]=[{id:'framely-wolvic/com.igalia.wolvic',context:'framely-wolvic',metadata:meta('com.igalia.wolvic','Wolvic'),steamLaunch:true,steamRegistered:true,installed:true,running:false,managed:true,size:128*1024*1024},{id:'dev/com.example.service',context:'dev',metadata:meta('com.example.service','Background service',false),installed:true,running:true,managed:false,size:20*1024*1024},{id:'old/com.example.notes',context:'old',metadata:meta('com.example.notes','Notes'),installed:false,running:false,managed:true,size:4*1024*1024}];
|
||||
const containers=[{acceptsAdditionalApps:false,id:'framely-wolvic',name:'framely-wolvic',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/framely-wolvic/baked'},{acceptsAdditionalApps:true,id:'dev',name:'dev',managed:false,running:true,baked:'/home/steamos/.local/share/lepton/contexts/dev/baked'},{id:'old',name:'old',managed:true,running:false,baked:'/home/steamos/.local/share/lepton/contexts/old/baked'}];
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let counter=0,fail=false,gamepadEnabled=false,autoStopContainer=false,stopContainerOnClose=true,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
const calls:{method:string;params:any}[]=[];const jobs=new Map<string,any>();let uninstallError:string|null=null;let counter=0,fail=false,gamepadEnabled=false,autoStopContainer=false,stopContainerOnClose=true,cleanupItems:any[]=[{id:'data:old/com.example.notes',kind:'data',name:'Notes',bytes:4*1024*1024,sensitive:true,app:'old/com.example.notes'},{id:'backups:backup1',kind:'backups',name:'20261005-backup',bytes:128*1024*1024,sensitive:true,context:'framely-wolvic',restorable:true},{id:'unknown:dev/com.example.orphan',kind:'unknown',name:'com.example.orphan',bytes:20,sensitive:true,unknown:true}];
|
||||
window.fetch=async(url,init)=>{if(String(url).startsWith('/host/files/')){const p=JSON.parse(String(init?.body));calls.push({method:String(url),params:p});let result:any=true;if(String(url).endsWith('/list'))result={path:'/home/steamos/Downloads',parent:'/home/steamos',roots:[],entries:[{name:'device.apk',path:'/home/steamos/Downloads/device.apk',directory:false,size:1024}],total:1,offset:0,skipped:0};else if(String(url).endsWith('/apk-inspect')){const id='job-'+counter++;jobs.set(id,{id,kind:'apk',phase:'done',result:{ticket:'local-review-ticket',metadata:meta('com.example.local','Local APK'),bytes:1024},received:0});result={job:id};}return new Response(JSON.stringify({result}));}if(String(url).startsWith('/api/upload/')){const offset=+String(url).split('/').at(-1)!;return new Response(JSON.stringify({result:{received:offset+(init?.body as Blob).size}}));}const{method,params}=JSON.parse(String(init?.body));calls.push({method,params});let result:any=true;
|
||||
if(method==='apk.upload.start')result={upload:'fixture-upload',chunkSize:512*1024};
|
||||
else if(method==='apk.cleanup.list')result=cleanupItems;
|
||||
else if(method==='diagnostics.export')result={name:'logs.zip',path:'/home/steamos/Downloads/logs.zip'};
|
||||
else if(method==='apk.logs')result={name:'apk.log',text:'Installation complete\nAndroid package manager: Success'};
|
||||
else if(method==='apk.list.start'||method==='apk.inspect.start'||method==='apk.operation.start'){
|
||||
const id='job-'+counter++;let value:any=true;
|
||||
if(method==='apk.list.start')value={apps:structuredClone(apps),containers,roots:[],warnings:[],available:true,gamepadEnabled,autoStopContainer,stopContainerOnClose};
|
||||
else if(method==='apk.inspect.start')value={ticket:'review-ticket',metadata:meta('com.example.new','New APK'),bytes:4};
|
||||
else{if(params.operation==='lifecycle.settings'){autoStopContainer=params.autoStopContainer;stopContainerOnClose=params.stopContainerOnClose;}if(params.operation==='gamepad.settings')gamepadEnabled=params.enabled;const app=apps.find(a=>a.id===params.app);if(params.operation==='steam.settings')app.steamLaunch=params.enabled;if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall')app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
jobs.set(id,{id,kind:'apk',phase:fail&&method==='apk.operation.start'?'failed':'done',result:value,error:'Fixture signature mismatch',received:0});result={job:id};
|
||||
else{if(params.operation==='lifecycle.settings'){autoStopContainer=params.autoStopContainer;stopContainerOnClose=params.stopContainerOnClose;}if(params.operation==='gamepad.settings')gamepadEnabled=params.enabled;const app=apps.find(a=>a.id===params.app);if(params.operation==='steam.settings')app.steamLaunch=params.enabled;if(params.operation==='settings')Object.assign(app,{activity:params.activity,showWindow:params.showWindow});if(params.operation==='uninstall'&&!uninstallError)app.installed=false;if(params.operation==='cleanup'){cleanupItems=cleanupItems.filter(i=>!params.items.includes(i.id));value={freedBytes:4*1024*1024,failed:[]};}}
|
||||
jobs.set(id,{id,kind:'apk',phase:method==='apk.operation.start'&&(fail||(params.operation==='uninstall'&&uninstallError))?'failed':'done',result:value,error:uninstallError??'Fixture signature mismatch',received:0});result={job:id};
|
||||
}else if(method==='job.status')result=jobs.get(params.job);
|
||||
return new Response(JSON.stringify({result}));};
|
||||
const root=createRoot(document.getElementById('root')!);function render(){root.render(<div className="app manager"><header className="shell-header"><Brand/><span>Framely</span></header><div className="workspace"><nav>{[['installed','已安装'],['download','插件库'],['download','APK 管理'],['settings','设置']].map(([icon,key])=><button className={key==='APK 管理'?'active':''} key={key}><Icon name={icon as any}/>{t(key)}</button>)}</nav><main className="content"><ApkManager/><FrameFilePicker/></main></div></div>);}
|
||||
@@ -81,6 +82,15 @@ async function snapshot(name:string){await wait(220);console.log('FRAMELY_PREVIE
|
||||
purgeSwitch().click();await until(()=>purgeSwitch().getAttribute('aria-checked')==='true');if(!deleteSwitch().disabled||deleteSwitch().getAttribute('aria-checked')!=='false')throw Error('Shared container selected for deletion');button('Cancel',document.querySelector('.apk-dialog')!).click();await wait();button('Done',document.querySelector('.apk-dialog')!).click();apps.pop();
|
||||
root.render(<div/>);await wait();render();await until(()=>document.querySelectorAll('.apk-row').length>=3);
|
||||
button('Manage',[...document.querySelectorAll('.apk-row')].find(r=>r.textContent?.includes('Wolvic'))!).click();await until(()=>document.querySelector('.apk-dialog'));button('Uninstall app',document.querySelector('.apk-dialog')!).click();await until(()=>!!document.querySelector('.apk-uninstall'));purgeSwitch().click();await until(()=>deleteSwitch().getAttribute('aria-checked')==='true');
|
||||
button('Uninstall and delete container',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='uninstall'&&c.params.deleteContainer));if(!calls.find(c=>c.params.operation==='uninstall'&&c.params.deleteContainer)!.params.purge)throw Error('Container deletion omitted purge');
|
||||
deleteSwitch().click();purgeSwitch().click();await until(()=>purgeSwitch().getAttribute('aria-checked')==='false');
|
||||
uninstallError='Android PackageManager did not become ready; export logs and retry.';
|
||||
button('Uninstall',document.querySelector('.apk-dialog')!).click();await until(()=>!!document.querySelector('.apk-uninstall .error'));
|
||||
if(!apps[0].installed)throw Error('Failed uninstall removed app');
|
||||
if(!document.querySelector('.apk-uninstall .error')?.textContent?.includes('PackageManager'))throw Error('Missing specific readiness error');
|
||||
button('Export logs',document.querySelector('.apk-dialog')!).click();await until(()=>[...document.querySelectorAll('.apk-uninstall [role=status]')].some(e=>e.textContent?.includes('/home/steamos/Downloads/logs.zip')));
|
||||
if(!calls.some(c=>c.method==='diagnostics.export'))throw Error('Uninstall diagnostics not exported');
|
||||
button('Retry',document.querySelector('.apk-dialog')!).click();await wait();await until(()=>!document.querySelector('progress')&&!!document.querySelector('.apk-uninstall .error')&&!button('Retry',document.querySelector('.apk-dialog')!).disabled);
|
||||
uninstallError=null;purgeSwitch().click();await until(()=>deleteSwitch().getAttribute('aria-checked')==='true');
|
||||
button('Retry',document.querySelector('.apk-dialog')!).click();await until(()=>calls.some(c=>c.params.operation==='uninstall'&&c.params.deleteContainer));if(!calls.find(c=>c.params.operation==='uninstall'&&c.params.deleteContainer)!.params.purge)throw Error('Container deletion omitted purge');
|
||||
console.log('FRAMELY_APK_MANAGER_PASS');console.log('FRAMELY_BRIDGE_PASS');
|
||||
})().catch(e=>console.error('FRAMELY_BRIDGE_FAIL '+e));
|
||||
@@ -968,6 +968,12 @@
|
||||
"提示音反馈": "Sound feedback",
|
||||
"指向可交互控件时振动,不影响 APK 游戏振动。": "Vibrate when pointing at interactive controls. APK game rumble is unaffected.",
|
||||
"使用 Steam 提示音反馈聚焦和点击。": "Use Steam sounds for focus and clicks.",
|
||||
"导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。": "Export Framely, plugin, renderer and APK/Lepton logs with service status. Plugin settings and access password files are excluded."
|
||||
"导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。": "Export Framely, plugin, renderer and APK/Lepton logs with service status. Plugin settings and access password files are excluded.",
|
||||
"Android startup timed out; export logs and retry.": "Android startup timed out; export logs and retry.",
|
||||
"Android PackageManager did not become ready; export logs and retry.": "Android PackageManager did not become ready; export logs and retry.",
|
||||
"Android user did not unlock; export logs and retry.": "Android user did not unlock; export logs and retry.",
|
||||
"Android external storage did not mount; export logs and retry.": "Android external storage did not mount; export logs and retry.",
|
||||
"Android container stopped before becoming ready; export logs and retry.": "Android container stopped before becoming ready; export logs and retry.",
|
||||
"Android readiness probe did not respond; export logs and retry.": "Android readiness probe did not respond; export logs and retry."
|
||||
}
|
||||
}
|
||||
@@ -958,6 +958,12 @@
|
||||
"提示音反馈": "提示音反馈",
|
||||
"指向可交互控件时振动,不影响 APK 游戏振动。": "指向可交互控件时振动,不影响 APK 游戏振动。",
|
||||
"使用 Steam 提示音反馈聚焦和点击。": "使用 Steam 提示音反馈聚焦和点击。",
|
||||
"导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。": "导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。"
|
||||
"导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。": "导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。",
|
||||
"Android startup timed out; export logs and retry.": "Android 启动超时,请导出日志后重试。",
|
||||
"Android PackageManager did not become ready; export logs and retry.": "Android 包管理器尚未就绪,请导出日志后重试。",
|
||||
"Android user did not unlock; export logs and retry.": "Android 用户未解锁,请导出日志后重试。",
|
||||
"Android external storage did not mount; export logs and retry.": "Android 外部存储未挂载,请导出日志后重试。",
|
||||
"Android container stopped before becoming ready; export logs and retry.": "Android 容器在就绪前退出,请导出日志后重试。",
|
||||
"Android readiness probe did not respond; export logs and retry.": "Android 就绪状态查询无响应,请导出日志后重试。"
|
||||
}
|
||||
}
|
||||
@@ -13,3 +13,14 @@ export async function uploadChunk(upload:string,offset:number,chunk:Blob,signal:
|
||||
if(!r.ok)throw new Error(t('服务请求失败:{0}',{0:r.status}));const v=await r.json();if(v.error)throw new Error(t(v.error));
|
||||
if(v.result?.received!==offset+chunk.size)throw new Error('Upload offset mismatch');
|
||||
}
|
||||
|
||||
export async function exportDiagnosticLogs():Promise<string>{
|
||||
const v=await api<{name:string;path?:string;data?:string}>('diagnostics.export');
|
||||
if(v.path)return t('日志已保存到:{0}',{0:v.path});
|
||||
if(!v.data)throw Error(t('日志包缺少数据'));
|
||||
const bytes=Uint8Array.from(atob(v.data),c=>c.charCodeAt(0));
|
||||
const url=URL.createObjectURL(new Blob([bytes],{type:'application/zip'}));
|
||||
const link=document.createElement('a');link.href=url;link.download=v.name;
|
||||
document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);
|
||||
return t('日志包已生成,请查看浏览器下载。');
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
|
||||
useEffect(()=>{if(view==='cleanup')void loadCleanup();},[view]);
|
||||
const input=useRef<HTMLInputElement>(null),controller=useRef<AbortController|null>(null),selection=useRef<{app:string;uninstall:boolean}|null>(null),alive=useRef(true),busyRef=useRef(false);
|
||||
const updateJob=(v:Job)=>{if(alive.current)setJob(v);};
|
||||
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');if(!active.job||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean;operation?:string}>('job.status',{job:active.job});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(value.phase==='failed')setError(value.error??t('操作失败'));else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(String(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
|
||||
async function resumeOperation(){let ownsBusy=false;try{const active=await api<{job?:string}>('apk.operation.active');if(!active.job||!alive.current||busyRef.current)return;ownsBusy=true;busyRef.current=true;setBusy(true);while(alive.current){const value=await api<Job&{cancellable?:boolean;operation?:string}>('job.status',{job:active.job});if(['done','failed','cancelled'].includes(value.phase)){setJob(null);if(value.phase==='failed')setError(t(value.error??'操作失败'));else setNotice(t('操作完成'));await refresh();break;}setJob(value);await new Promise(resolve=>setTimeout(resolve,300));}}catch(e){if(alive.current)setError(String(e));}finally{if(ownsBusy&&alive.current){busyRef.current=false;setBusy(false);}}}
|
||||
useEffect(()=>{if(data.retainSteamEntries===false||!data.apps.some(a=>a.installed&&!a.steam&&a.stateKnown!==false&&!a.pending&&(a.metadata.activities.length||a.activity)&&(a.steamLaunch??true)&&!a.steamRegistered))return;const timer=setInterval(()=>{if(!busyRef.current)void refresh();},20000);return()=>clearInterval(timer);},[data.apps,data.retainSteamEntries]);
|
||||
async function refresh(){setLoading(true);try{const r=await runJob('apk.list.start');if(alive.current){setData(r.result);setDetails(old=>old?r.result.apps.find((a:App)=>a.id===old.id)??null:null);}}catch(e){if(alive.current)setError(String(e));}finally{if(alive.current)setLoading(false);}}
|
||||
useEffect(()=>{alive.current=true;void refresh();void resumeOperation();const select=(e:Event)=>{const v=(e as CustomEvent).detail;if(v?.kind==='apk.selection'){selection.current={app:v.app,uninstall:!!v.uninstall};void refresh();}};window.addEventListener('framely.event',select);return()=>{alive.current=false;window.removeEventListener('framely.event',select);controller.current?.abort();};},[]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import React,{useState} from 'react';
|
||||
import {exportDiagnosticLogs} from './api';
|
||||
import {IconAlertTriangle} from '@tabler/icons-react';
|
||||
import {PluginImage} from './store';
|
||||
import {SwitchRow} from './switch';
|
||||
@@ -12,6 +13,8 @@ type Props={
|
||||
};
|
||||
|
||||
export function ApkUninstall({app,size,purge,deleteContainer,exclusive,busy,error,onPurge,onDeleteContainer,onCancel,onConfirm}:Props){
|
||||
const [exporting,setExporting]=useState(false),[diagnostic,setDiagnostic]=useState('');
|
||||
async function exportLogs(){setExporting(true);try{setDiagnostic(await exportDiagnosticLogs());}catch(e){setDiagnostic(String(e));}finally{setExporting(false);}}
|
||||
const message=deleteContainer?'将永久删除应用及容器数据。历史备份保留。':purge?'将永久删除存档和应用数据。容器保留。':'存档和设置会保留,之后可以重新安装。';
|
||||
return <div className="apk-uninstall">
|
||||
<h2>{t(app.installed?'卸载应用':'清除保留数据')}</h2>
|
||||
@@ -28,6 +31,7 @@ export function ApkUninstall({app,size,purge,deleteContainer,exclusive,busy,erro
|
||||
{purge&&<IconAlertTriangle size={22} aria-hidden="true"/>}<p>{t(message)}{purge&&<small>{t('永久删除的数据无法撤销。')}</small>}</p>
|
||||
</div>
|
||||
{error&&<p className="error" role="alert">{error}</p>}
|
||||
<footer><button disabled={busy} onClick={onCancel}>{t('取消')}</button><button className="danger" disabled={busy} onClick={onConfirm}>{t(app.installed?(deleteContainer?'卸载并删除容器':'卸载'):(deleteContainer?'删除数据和容器':'删除'))}</button></footer>
|
||||
{diagnostic&&<p role="status">{diagnostic}</p>}
|
||||
<footer>{error&&<button disabled={busy||exporting} onClick={()=>void exportLogs()}>{t(exporting?'正在导出日志…':'导出日志')}</button>}<button disabled={busy} onClick={onCancel}>{t('取消')}</button><button className="danger" disabled={busy} onClick={onConfirm}>{error?t('重试'):t(app.installed?(deleteContainer?'卸载并删除容器':'卸载'):(deleteContainer?'删除数据和容器':'删除'))}</button></footer>
|
||||
</div>;
|
||||
}
|
||||
+2
-5
@@ -22,7 +22,7 @@ import {InstallReview} from './install-review';
|
||||
import {MemoryLimit} from './memory-limit';
|
||||
import {installScrollbars} from '../../sdk/src/scrollbars';
|
||||
installScrollbars();
|
||||
import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';if(location.pathname==='/launcher')document.documentElement.classList.add('launcher-view');if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view');
|
||||
import React,{useEffect,useRef,useState} from 'react';import{createRoot}from'react-dom/client';import{uploadChunk,api,viewKey,exportDiagnosticLogs}from'./api';import {Icon,Brand} from './icons';import {Store,Sources,PluginImage,PluginLinks,PluginAuthor,installedIcon,runJob,Transfer,Job,Presentation,compareVersions} from './store';import {SystemUpdates} from './updates';import './style.css';if(location.pathname==='/launcher')document.documentElement.classList.add('launcher-view');if(location.pathname==='/notifications')document.documentElement.classList.add('notification-view');
|
||||
export type Manifest=Presentation&{id:string;name:string;version:string;author:string;description:string;backend?:{runAs:string;autostart:boolean;memoryLimitMiB?:number};lifecycle?:{runAs?:string};dependencies?:Record<string,unknown>;optionalDependencies?:Record<string,unknown>;conflicts?:Record<string,string>;exclusiveResources?:string[];engines?:{framely?:string};ui:{quickPage?:string;windows:Record<string,unknown>;launch?:Record<string,{type:string;window?:string}>;launcherActions?:{id:string;label:string;target:{type:string;entry?:string;window?:string}}[]}};
|
||||
export type Plugin={manifest:Manifest;enabled:boolean;favorite:boolean;order:number;source?:string;error?:string};
|
||||
type Source={id:string;name:string;url:string;enabled:boolean;allowHttp:boolean};
|
||||
@@ -43,10 +43,7 @@ function App(){
|
||||
function switchManager(section:ManagerSection){let target: string=({plugins:'installed',apk:'apk',terminal:'terminal',files:'files',settings:'settings'})[section];try{const remembered=localStorage.getItem('framely.manager.'+section);if(remembered&§ionFor(remembered)===section&&managerTab('#'+remembered)===remembered)target=remembered;}catch{}setPage(null);setManage(null);setTab(target);history.replaceState(null,'',location.pathname+location.search+'#'+target);}
|
||||
async function exportLogs(){
|
||||
setExportingLogs(true);setLogExport('');setError('');
|
||||
try{const v=await api<{name:string;path?:string;data?:string}>('diagnostics.export');
|
||||
if(v.path)setLogExport(t('日志已保存到:{0}',{0:v.path}));
|
||||
else if(v.data){const bytes=Uint8Array.from(atob(v.data),c=>c.charCodeAt(0));const url=URL.createObjectURL(new Blob([bytes],{type:'application/zip'}));const link=document.createElement('a');link.href=url;link.download=v.name;document.body.appendChild(link);link.click();link.remove();setTimeout(()=>URL.revokeObjectURL(url),60000);setLogExport(t('日志包已生成,请查看浏览器下载。'));}
|
||||
else throw Error(t('日志包缺少数据'));
|
||||
try{setLogExport(await exportDiagnosticLogs());
|
||||
}catch(e){setError(String(e));}finally{setExportingLogs(false);}
|
||||
}
|
||||
async function refreshLanguages(){const v=await api('language.list');localePacks.current=v.packs??[];setLanguageFiles(v.invalidFiles??[]);await refresh();}
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ export async function runJob(method:string,params:unknown={},onUpdate?:(job:Job)
|
||||
export async function waitJob(id:string,onUpdate?:(job:Job)=>void,statusMethod='job.status'):Promise<{job:string;result:any}>{
|
||||
const started={job:id};
|
||||
const deadline=Date.now()+35*60*1000;
|
||||
while(Date.now()<deadline){const job=await api<Job>(statusMethod,{job:started.job});onUpdate?.(job);if(job.phase==='done')return{job:started.job,result:job.result};if(job.phase==='failed')throw new Error(job.error??t("操作失败"));if(job.phase==='cancelled')throw new Error(t("操作已取消"));await new Promise(r=>setTimeout(r,300));}
|
||||
while(Date.now()<deadline){const job=await api<Job>(statusMethod,{job:started.job});onUpdate?.(job);if(job.phase==='done')return{job:started.job,result:job.result};if(job.phase==='failed')throw new Error(t(job.error??"操作失败"));if(job.phase==='cancelled')throw new Error(t("操作已取消"));await new Promise(r=>setTimeout(r,300));}
|
||||
throw new Error(t("操作超时,请重新检查任务状态"));
|
||||
}
|
||||
export function PluginImage({src,name,size=48}:{src?:string|null;name:string;size?:number}){const[failed,setFailed]=useState(false);useEffect(()=>setFailed(false),[src]);return <span className="plugin-avatar" style={{width:size,height:size}}>{src&&!failed?<img src={src} alt={t("{0}图标", {"0": name})} onError={()=>setFailed(true)}/>:<Icon name="plugins" size={Math.round(size*.55)}/>}</span>}
|
||||
|
||||
Reference in new issue
Block a user