fix(apk): label containers by app and offer close action during install

This commit is contained in:
toorux committed 2026-10-07 11:35:35 +08:00
1 parent 77a10fbdd3
commit 6d2b4ca0a0
4 files changed
+12 -3

No files matched your search

+2
View File
@@ -150,3 +150,5 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
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.
+2
View File
@@ -138,3 +138,5 @@ Steam 入口显示 APK 的应用名称和图标,并根据图标生成对应的
名称、图片和启动适配通过 Steam 已有的本地库界面 API 配置,连接其 8080 端口的本地调试接口。Framely 不开启该接口,也不为此修改 Steam 启动设置。API 不可用或快捷方式目标验证失败时,注册显示错误并重试,不直接修改运行中的快捷方式数据库,也不悄悄切换为无数据保护的启动。此 Steam 界面接口可能随 Steam 更新变化。
关闭原生 Steam 应用后,才能关闭“通过 Steam 启动”;关闭开关只移除 Framely 的入口。卸载 APK 时先停止其原生容器,再移除入口。只有明确选择的卸载或数据清理操作才删除应用数据。用户会话服务继续核对进程及容器启动标识,防止旧占用记录停止新的应用。
容器列表标题显示已安装应用名称,数据路径用于识别容器。更新 APK 会先停止容器再备份数据。如果停止失败,或原生 Steam 启动进程尚未释放容器,安装确认中提供 **关闭容器** 按钮并要求确认。关闭会中断运行中的应用,保留存档;已检查的 APK 会保留,可关闭后重试安装。
+4 -1
View File
@@ -921,6 +921,9 @@
"通过 Steam 启动": "Launch through Steam",
"保存 Steam 启动设置": "Save Steam launch settings",
"默认开启,自动包装已有和新安装的侧载应用。注册不启动应用或重启容器;首次通过 Steam 启动前请先关闭运行中的容器。": "Enabled by default. Existing and newly installed sideloaded apps are wrapped automatically. Registration does not start apps or restart containers. Close a running container before its first Steam launch.",
"等待 Steam 注册,Steam 就绪后会自动重试。": "Waiting for Steam registration. It will retry automatically when Steam is ready."
"等待 Steam 注册,Steam 就绪后会自动重试。": "Waiting for Steam registration. It will retry automatically when Steam is ready.",
"空容器": "Empty container",
"关闭容器": "Close container",
"安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。": "Close the container before installation to safely back up app data. This interrupts apps running inside it and retains saves and settings."
}
}
+4 -2
View File
@@ -43,6 +43,8 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
const listed=data.apps.filter(a=>(filter==='all'||(filter==='installed'?(a.installed||a.stateKnown===false):(!a.installed&&a.stateKnown!==false)))&&!a.steam&&`${a.metadata.name} ${a.metadata.package} ${a.context}`.toLowerCase().includes(query.toLowerCase()));
const contextFor=(id:string)=>data.containers.find(c=>c.id===id);
function containerAsk(c:Container,operation:string){setConfirm({operation,container:c,title:t(operation==='container.delete'?'删除容器':operation==='container.stop'?'停止容器':'启动容器'),description:`${t('此操作影响以下应用:')} ${data.apps.filter(a=>a.context===c.id).map(a=>a.metadata.name).join(', ')||c.name}${operation==='container.delete'?`\n${t('容器内全部数据将永久删除。')}`:''}`});}
const containerTitle=(c:Container)=>data.apps.filter(a=>a.context===c.id&&a.installed).map(a=>a.metadata.name).join(', ')||t('空容器');
const installContainer=review?contextFor(target||updating?.context||data.apps.find(a=>!a.steam&&a.metadata.package===review.metadata.package)?.context||''):undefined;
const deletesData=!!confirm&&(['purge','clear','cleanup','container.delete'].includes(confirm.operation)||(confirm.operation==='uninstall'&&!!confirm.purge));
const dialog=(children:React.ReactNode,label:string)=>createPortal(<div className="apk-modal-backdrop"><div className="modal apk-dialog" role="dialog" aria-modal="true" aria-label={label}>{error&&!review&&!confirm&&<p className="error" role="alert">{error}</p>}{children}</div></div>,document.body);
return <section className="apk-manager">
@@ -53,11 +55,11 @@ export function ApkManager({view:externalView,onViewChange}:{view?:string;onView
{!externalView&&<div className="apk-tabs" role="tablist">{[['apps','应用'],['containers','容器'],['cleanup','数据清理'],['settings','设置']].map(([key,label])=><button key={key} role="tab" aria-selected={view===key} className={view===key?'active':''} disabled={busy} onClick={()=>{setView(key);if(key==='cleanup')void loadCleanup();}}>{t(label)}</button>)}</div>}
{view==='settings'&&<><SwitchRow label={t('应用退出后自动停止容器')} description={t('默认关闭。应用进程连续消失 15 秒后停止容器;切到后台不会停止。')} checked={!!data.autoStopContainer} disabled={busy||loading} onChange={autoStopContainer=>void perform('lifecycle.settings',{autoStopContainer,stopContainerOnClose:data.stopContainerOnClose??true})}/><SwitchRow label={t('关闭窗口时停止容器')} description={t('收到系统窗口关闭信号后停止容器并保留数据。部分 SteamOS 版本不支持关闭 Lepton 窗口;切到后台不会停止,共享容器中的其他应用会受到保护。')} checked={data.stopContainerOnClose??true} disabled={busy||loading} onChange={stopContainerOnClose=>void perform('lifecycle.settings',{autoStopContainer:!!data.autoStopContainer,stopContainerOnClose})}/><SwitchRow label={t('Frame 手柄输入')} description={t('默认关闭。将 Frame 手柄用作标准游戏手柄;开启后下次打开 APK 生效,必要时重启容器。关闭立即停止输入。')} checked={!!data.gamepadEnabled} disabled={busy||loading||!data.available&&!data.gamepadEnabled} onChange={enabled=>void perform('gamepad.settings',{enabled})}/></>}
{view==='apps'&&<><div className="apk-filters"><input type="search" aria-label={t('搜索 APK')} placeholder={t('搜索名称、包名或容器')} value={query} onChange={e=>setQuery(e.target.value)}/><Select label={t('应用筛选')} value={filter} onChange={setFilter} options={[{value:'installed',label:t('已安装')},{value:'retained',label:t('已卸载但保留数据')},{value:'all',label:t('全部')}]}/></div><div className="apk-list">{listed.map(a=><article className="apk-row" key={a.id}><PluginImage src={a.metadata.icon} name={a.metadata.name} size={64}/><div className="apk-copy"><h2>{a.metadata.name}<small>{a.metadata.version}</small></h2><code>{a.metadata.package}</code><p>{contextFor(a.context)?.name??a.context} · {bytes(a.size)} · {t(a.stateKnown===false?'安装状态未知':a.installed?(a.running?'容器运行中':'已安装'):'已卸载但保留数据')}</p>{a.pending&&<p className="error">{t(a.pending)}</p>}{a.installed&&!a.metadata.activities.length&&!a.activity&&<p>{t('未发现启动入口')}</p>}</div><div className="apk-actions">{a.installed&&a.stateKnown!==false&&<button disabled={busy||!a.metadata.activities.length&&!a.activity} onClick={()=>void perform('launch',{app:a.id})}><IconPlayerPlay size={20}/>{t('打开')}</button>}<button disabled={busy} onClick={()=>setDetails(a)}>{t('管理')}</button></div></article>)}</div>{!listed.length&&<div className="empty"><IconBrandAndroid size={40}/><p>{loading?t('正在读取'):t('没有匹配的 APK 应用。')}</p></div>}</>}
{view==='containers'&&<><div className="apk-list">{data.containers.map(c=><article className="apk-container" key={c.id}><div className="apk-copy"><h2><IconFolder size={24}/>{c.name}</h2><code>{c.baked}</code><p>{t(c.steam?'Steam 管理':c.managed?'Framely 管理':'已有侧载容器')} · {t(c.running?'运行中':'已停止')}</p><p>{data.apps.filter(a=>a.context===c.id&&a.installed).map(a=>a.metadata.name).join(', ')||t('未发现已安装的应用,启动容器不会安装 APK。')}</p></div><div className="apk-actions"><button disabled={busy} onClick={()=>void showLog(c.id)}><IconFileText size={20}/>{t('日志')}</button>{!c.steam&&<><button disabled={busy} onClick={()=>containerAsk(c,c.running?'container.stop':'container.start')}>{t(c.running?'停止容器':'启动容器')}</button><button className="danger" disabled={busy} onClick={()=>containerAsk(c,'container.delete')}>{t('删除容器')}</button></>}</div></article>)}</div><section className="apk-locations"><h2>{t('其他数据位置')}</h2><p>{t('添加其他工具使用的 baked 数据目录,不移动原有应用。')}</p><div className="apk-filters"><input value={path} placeholder="/path/to/compatdata/baked" aria-label={t('Lepton 数据目录')} onChange={e=>setPath(e.target.value)}/><input value={contextName} placeholder={t('原容器名称(可选)')} aria-label={t('原容器名称(可选)')} onChange={e=>setContextName(e.target.value)}/><button disabled={busy||!path.trim()} onClick={()=>void perform('root.add',{path,context:contextName}).then(()=>setPath(''))}>{t('添加')}</button></div>{data.roots.map(p=><div className="apk-location" key={p}><code>{p}</code><button disabled={busy} onClick={()=>void perform('root.remove',{path:p})}>{t('移除扫描位置')}</button></div>)}</section></>}
{view==='containers'&&<><div className="apk-list">{data.containers.map(c=><article className="apk-container" key={c.id}><div className="apk-copy"><h2><IconFolder size={24}/>{containerTitle(c)}</h2><code>{c.baked}</code><p>{t(c.steam?'Steam 管理':c.managed?'Framely 管理':'已有侧载容器')} · {t(c.running?'运行中':'已停止')}</p>{!data.apps.some(a=>a.context===c.id&&a.installed)&&<p>{t('未发现已安装的应用,启动容器不会安装 APK。')}</p>}</div><div className="apk-actions"><button disabled={busy} onClick={()=>void showLog(c.id)}><IconFileText size={20}/>{t('日志')}</button>{!c.steam&&<><button disabled={busy} onClick={()=>containerAsk(c,c.running?'container.stop':'container.start')}>{t(c.running?'停止容器':'启动容器')}</button><button className="danger" disabled={busy} onClick={()=>containerAsk(c,'container.delete')}>{t('删除容器')}</button></>}</div></article>)}</div><section className="apk-locations"><h2>{t('其他数据位置')}</h2><p>{t('添加其他工具使用的 baked 数据目录,不移动原有应用。')}</p><div className="apk-filters"><input value={path} placeholder="/path/to/compatdata/baked" aria-label={t('Lepton 数据目录')} onChange={e=>setPath(e.target.value)}/><input value={contextName} placeholder={t('原容器名称(可选)')} aria-label={t('原容器名称(可选)')} onChange={e=>setContextName(e.target.value)}/><button disabled={busy||!path.trim()} onClick={()=>void perform('root.add',{path,context:contextName}).then(()=>setPath(''))}>{t('添加')}</button></div>{data.roots.map(p=><div className="apk-location" key={p}><code>{p}</code><button disabled={busy} onClick={()=>void perform('root.remove',{path:p})}>{t('移除扫描位置')}</button></div>)}</section></>}
{view==='cleanup'&&<><div className="apk-cleanup-heading"><div><h2>{t('数据清理')}</h2><p>{t('存档、设置与恢复备份默认不选中。未知归属的数据不会自动删除。')}</p></div><button className="danger" disabled={busy||!selected.length} onClick={()=>setConfirm({operation:'cleanup',items:selected,title:t('确认数据清理'),description:cleanup.filter(i=>selected.includes(i.id)).map(i=>`${i.name} · ${bytes(i.bytes)}`).join('\n')+`\n${t('选中的文件将永久删除,无法撤销。')}`})}><IconTrash size={22}/>{t('清理所选')}</button></div>{cleanup.map(i=><article className="apk-cleanup-row" key={i.id}><Switch checked={selected.includes(i.id)} label={`${t('选择')} ${i.name}`} disabled={busy||i.unknown} onChange={checked=>setSelected(s=>checked?[...s,i.id]:s.filter(v=>v!==i.id))}/><div className="apk-copy"><b>{i.name}</b><p>{t(i.kind==='unknown'?'未知归属的数据':i.kind==='data'?'保留的应用数据':i.kind==='backups'?'恢复备份':(i.kind==='reviews'||i.kind==='uploads')?'过期上传文件':'旧 APK')} · {bytes(i.bytes)}</p>{i.unknown?<small>{t('无法确认归属,不提供自动删除。')}</small>:i.sensitive&&<small>{t('删除后不可恢复')}</small>}</div>{i.restorable&&<button disabled={busy} onClick={()=>setConfirm({operation:'restore',container:contextFor(i.context!),backup:i.name,title:t('恢复容器备份'),description:t('恢复将替换该容器内全部应用的状态;先保存恢复前备份,之后需启动验证。')})}>{t('恢复')}</button>}</article>)}{!cleanup.length&&<p className="empty">{t('没有可清理的数据。')}</p>}</>}
{!!data.warnings.length&&<details className="apk-warnings"><summary>{t('部分容器无法读取')}</summary>{data.warnings.map((w,i)=><p key={i}>{w}</p>)}</details>}
{details&&!confirm&&!review&&!log&&!job&&dialog(<><header className="apk-dialog-heading"><PluginImage name={details.metadata.name} src={details.metadata.icon} size={64}/><div><h2>{details.metadata.name}</h2><code>{details.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{details.metadata.version} ({details.metadata.versionCode})</dd><dt>{t('容器')}</dt><dd>{contextFor(details.context)?.name??details.context}</dd><dt>{t('空间占用')}</dt><dd>{bytes(details.size)}</dd></dl>{details.pending&&<><p className="error">{t(details.pending)}</p><button disabled={busy} onClick={()=>void perform('reconcile',{app:details.id})}>{t('核对实际安装状态')}</button></>}<div className="apk-actions">{details.installed&&details.stateKnown!==false&&!details.steam&&<><button disabled={busy||!details.metadata.activities.length&&!details.activity} onClick={()=>void perform('launch',{app:details.id})}>{t('打开')}</button><button disabled={busy} onClick={()=>void perform('close',{app:details.id})}><IconPlayerStop size={20}/>{t('关闭应用')}</button><button disabled={busy} onClick={()=>choose(details)}>{t('安装新版 APK')}</button><button className="danger" disabled={busy} onClick={()=>setConfirm({operation:'clear',app:details,title:t('清除应用数据'),description:t('将永久删除该应用的存档和设置,无法撤销。')})}>{t('清除应用数据')}</button></>}{!details.installed&&details.stateKnown!==false&&!details.steam&&<button disabled={busy} onClick={()=>choose(details)}>{t('重新安装 APK')}</button>}<button disabled={busy} onClick={()=>void showLog(details)}><IconFileText size={20}/>{t('日志')}</button><button className="danger" disabled={busy||!!details.pending||details.stateKnown===false||details.steam} onClick={()=>askUninstall(details)}>{t(details.installed?'卸载应用':'清除保留数据')}</button></div>{details.installed&&details.stateKnown!==false&&!details.steam&&<details className="apk-advanced"><summary>{t('高级设置')}</summary><SwitchRow label={t('通过 Steam 启动')} description={t('默认开启,自动包装已有和新安装的侧载应用。注册不启动应用或重启容器;首次通过 Steam 启动前请先关闭运行中的容器。')} checked={details.steamLaunch??true} disabled={busy} onChange={enabled=>void perform('steam.settings',{app:details.id,enabled})}/>{(details.steamLaunch??true)&&!details.steamRegistered&&<p role="status">{t('等待 Steam 注册,Steam 就绪后会自动重试。')}</p>}{details.steamRegistrationError&&<p className="error">{details.steamRegistrationError}</p>}<p>{t('默认自动处理窗口显示。此开关不改变应用的二维或 VR 类型。')}</p><SwitchRow label={t('自动处理窗口显示')} checked={details.showWindow==null} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked?null:true})}/>{details.showWindow!=null&&<SwitchRow label={t('显示安卓窗口')} checked={details.showWindow} disabled={busy} onChange={checked=>void perform('settings',{app:details.id,showWindow:checked})}/>}<p>{t('窗口设置将在下次打开应用时应用;必要时自动重启容器。')}</p><label>{t('窗口方向')}<Select label={t('窗口方向')} value={details.orientation??'auto'} disabled={busy} onChange={orientation=>void perform('settings',{app:details.id,orientation})} options={[{value:'auto',label:t('自动选择')},{value:'landscape',label:t('横屏')},{value:'portrait',label:t('竖屏')}]}/></label><label>{t('启动入口')}<Select label={t('启动入口')} value={details.activity??''} disabled={busy} onChange={activity=>void perform('settings',{app:details.id,activity})} options={[{value:'',label:t('自动选择')},...details.metadata.declaredActivities.map(a=>({value:a,label:a}))]}/></label></details>}<footer><button onClick={()=>setDetails(null)}>{t('完成')}</button></footer></>,t('管理应用'))}
{review&&!job&&dialog(<><h2>{t(updating?'确认更新 APK':'确认安装 APK')}</h2><header className="apk-dialog-heading"><PluginImage name={review.metadata.name} src={review.metadata.icon} size={64}/><div><h3>{review.metadata.name}</h3><code>{review.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{updating&&`${updating.metadata.version} → `}{review.metadata.version} ({review.metadata.versionCode})</dd><dt>{t('文件大小')}</dt><dd>{bytes(review.bytes)}</dd><dt>{t('最低 Android SDK')}</dt><dd>{review.metadata.minSdk||t('未声明')}</dd><dt>{t('启动入口')}</dt><dd>{review.metadata.activities.length||t('未发现启动入口')}</dd></dl><label>{t('显示方式')}<Select label={t('显示方式')} value={displayMode} disabled={busy} onChange={setDisplayMode} options={[{value:'flat',label:t('平面窗口')},{value:'vr',label:t('VR 应用')}]}/></label><p className="sub">{t('平面模式显示二维窗口;VR 模式隐藏二维窗口,应用本身需要支持 VR。')}</p><label>{t('目标容器')}<Select label={t('目标容器')} value={target} disabled={true} onChange={setTarget} options={[{value:'',label:t('独立容器(默认)')},...data.containers.filter(c=>!c.steam&&c.id===updating?.context).map(c=>({value:c.id,label:c.name}))]}/></label>{!updating&&<p className="sub">{t('每个新应用使用独立容器,更新保留原容器。')}</p>}{target&&<p className="banner">{t('操作可能需要停止目标容器,影响其中正在运行的应用。')}</p>}{updating&&<p>{t('同包名更新保留数据;签名不匹配或降级不会自动卸载重装。')}</p>}{updating&&<p className="banner">{t('共享容器的备份需手动恢复;独立侧载容器支持一键恢复。')}</p>}{error&&<p className="error" role="alert">{error}</p>}<footer><button disabled={busy} onClick={()=>void discard()}>{t('取消')}</button><button className="primary" disabled={busy||!!updating&&updating.metadata.package!==review.metadata.package} onClick={()=>void perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true})}>{t('确认安装')}</button></footer></>,t('安装 APK'))}
{review&&!confirm&&!job&&dialog(<><h2>{t(updating?'确认更新 APK':'确认安装 APK')}</h2><header className="apk-dialog-heading"><PluginImage name={review.metadata.name} src={review.metadata.icon} size={64}/><div><h3>{review.metadata.name}</h3><code>{review.metadata.package}</code></div></header><dl className="apk-meta"><dt>{t('版本')}</dt><dd>{updating&&`${updating.metadata.version} → `}{review.metadata.version} ({review.metadata.versionCode})</dd><dt>{t('文件大小')}</dt><dd>{bytes(review.bytes)}</dd><dt>{t('最低 Android SDK')}</dt><dd>{review.metadata.minSdk||t('未声明')}</dd><dt>{t('启动入口')}</dt><dd>{review.metadata.activities.length||t('未发现启动入口')}</dd></dl><label>{t('显示方式')}<Select label={t('显示方式')} value={displayMode} disabled={busy} onChange={setDisplayMode} options={[{value:'flat',label:t('平面窗口')},{value:'vr',label:t('VR 应用')}]}/></label><p className="sub">{t('平面模式显示二维窗口;VR 模式隐藏二维窗口,应用本身需要支持 VR。')}</p><label>{t('目标容器')}<Select label={t('目标容器')} value={target} disabled={true} onChange={setTarget} options={[{value:'',label:t('独立容器(默认)')},...data.containers.filter(c=>!c.steam&&c.id===updating?.context).map(c=>({value:c.id,label:containerTitle(c)}))]}/></label>{!updating&&<p className="sub">{t('每个新应用使用独立容器,更新保留原容器。')}</p>}{installContainer?.running&&!installContainer.steam&&<div className="apk-notice"><p>{t('安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。')}</p><button disabled={busy} onClick={()=>containerAsk(installContainer,'container.stop')}>{t('关闭容器')}</button></div>}{updating&&<p>{t('同包名更新保留数据;签名不匹配或降级不会自动卸载重装。')}</p>}{updating&&<p className="banner">{t('共享容器的备份需手动恢复;独立侧载容器支持一键恢复。')}</p>}{error&&<div className="error" role="alert"><p>{error}</p>{installContainer&&!installContainer.steam&&!installContainer.running&&/container|backup|容器|备份/i.test(error)&&<button disabled={busy} onClick={()=>containerAsk(installContainer,'container.stop')}>{t('关闭容器')}</button>}</div>}<footer><button disabled={busy} onClick={()=>void discard()}>{t('取消')}</button><button className="primary" disabled={busy||!!updating&&updating.metadata.package!==review.metadata.package} onClick={()=>void perform('install',{ticket:review.ticket,context:target,app:updating?.id,showWindow:displayMode==='flat',approve:true})}>{t('确认安装')}</button></footer></>,t('安装 APK'))}
{confirm&&!job&&dialog(<><h2>{confirm.title}</h2><p className="apk-confirm-copy">{confirm.description}</p>{confirm.app&&<p><b>{confirm.app.metadata.name}</b><br/><code>{confirm.app.metadata.package}</code></p>}{confirm.operation==='uninstall'&&<SwitchRow label={t('删除应用数据')} description={t('关闭时保留存档和设置。')} checked={!!confirm.purge} disabled={busy} onChange={purge=>setConfirm(c=>c?{...c,purge}:c)}/>}{deletesData&&<p>{t('永久删除的数据无法撤销。')}</p>}{error&&<p className="error">{error}</p>}<footer><button disabled={busy} onClick={()=>setConfirm(null)}>{t('取消')}</button><button className={deletesData||confirm.operation==='uninstall'?'danger':'primary'} disabled={busy} onClick={()=>void perform(confirm.operation,{app:confirm.app?.id,context:confirm.container?.id,backup:confirm.backup,items:confirm.items,purge:confirm.purge,approve:true})}>{t('确认')}</button></footer></>,confirm.title)}
{job&&dialog(<><h2>{t(job.phase==='preparing'&&job.operation?operationLabels[job.operation]??phases[job.phase]:phases[job.phase]??job.phase)}</h2><p>{t('操作进行中,请等待实际结果。')}</p><progress value={job.phase==='uploading'?job.received:undefined} max={job.phase==='uploading'?job.total:undefined}/>{job.phase==='uploading'&&<p>{bytes(job.received)} / {bytes(job.total??0)}</p>}{job.cancellable===false?<p>{t('操作已开始,请等待完成;此阶段无法取消。')}</p>:<button onClick={()=>{if(job.id==='upload')controller.current?.abort();else void api('job.cancel',{job:job.id}).catch(e=>setError(String(e)));}}>{t('取消')}</button>}</>,t('APK 操作进度'))}
{log&&!job&&dialog(<><h2>{t('APK 日志')}</h2><pre>{log.text||t('暂无日志')}</pre><footer><button onClick={()=>setLog(null)}>{t('关闭')}</button><button onClick={downloadLog}>{t('导出日志')}</button></footer></>,t('APK 日志'))}