mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-23 11:01:57 +02:00
Include APK and Lepton Android logs in diagnostic exports
This commit is contained in:
1 parent
70862a79d8
commit
accae9cfbf
7 files changed
+189
-13
No files matched your search
@@ -63,3 +63,7 @@ Select an image or video to preview it. PNG, JPEG, GIF, WebP, AVIF, BMP, MP4, We
|
||||
## Access and privacy
|
||||
|
||||
Terminal, file and transfer routes use the existing authenticated manager connection and port. Mutating routes and the terminal WebSocket require an exact matching Origin; plugin sandboxes cannot use them. The service runs as the configured Steam user, independently of the root daemon. Preview/download content is read only after explicit selection. Test media are generated fixtures; Framely does not automatically read or upload Arcturus recordings.
|
||||
|
||||
## Diagnostic export
|
||||
|
||||
**Settings → General → Export logs** and the installer’s **Export logs** use the same collector. The archive includes recent APK operation logs, Lepton’s saved Android logs, and a limited snapshot of running containers: Android boot, user unlock, storage mounting, PackageManager readiness and recent main/system/crash logs. Rootless containers are inspected as the configured Steam session user. Stopped containers are not started. App state is summarized without tokens or icons; APK files, saves, media and raw memory dumps are excluded. File and archive limits retain recent output; missing sources, truncation and command timeouts are listed in `report.json`. Logs can contain application information; review before sharing.
|
||||
@@ -65,3 +65,7 @@
|
||||
## 访问与隐私
|
||||
|
||||
终端、文件与传输使用现有管理面板登录和端口;修改接口及终端 WebSocket 严格匹配 Origin,插件沙箱不可调用。接口运行于 Steam 用户会话服务,不借用 root 本体执行文件操作。预览与下载仅在用户主动选择后读取内容;测试媒体为生成的样本,不自动读取或上传 Arcturus 录制文件。
|
||||
|
||||
## 导出诊断日志
|
||||
|
||||
**设置 → 通用 → 导出日志** 和安装器的 **导出日志** 使用同一收集流程。日志包包含近期 APK 操作日志、Lepton 已保存的 Android 日志,以及运行中容器的有限快照:Android 启动、用户解锁、存储挂载、包管理器就绪状态和近期 main/system/crash 日志。以配置的 Steam 会话用户检查无 root 容器,不启动已停止的容器。应用状态只导出摘要,排除令牌和图标;不读取 APK、存档、媒体或原始内存转储。单文件及总大小受限,保留近期输出;缺失来源、截断和命令超时记录在 `report.json`。日志可能包含应用信息,分享前请检查。
|
||||
@@ -5,6 +5,9 @@ import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from types import SimpleNamespace
|
||||
import subprocess
|
||||
import zipfile
|
||||
|
||||
spec = importlib.util.spec_from_file_location('diagnostics', pathlib.Path(__file__).parents[1] / 'tools/export-diagnostics.py')
|
||||
@@ -56,5 +59,66 @@ class DiagnosticsTests(unittest.TestCase):
|
||||
self.assertLessEqual(len(data), diag.MAX_FILE)
|
||||
self.assertTrue(data.endswith(b'LAST ERROR'))
|
||||
|
||||
def test_apk_logs_summary_are_bounded_and_exclude_application_files(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = pathlib.Path(temp)
|
||||
home = root / 'user'
|
||||
apk = home / '.local/share/framely/apk-manager'
|
||||
(apk / 'logs').mkdir(parents=True)
|
||||
(apk / 'apks').mkdir()
|
||||
(apk / 'apks/private.apk').write_text('PRIVATE_APK_CONTENT')
|
||||
(apk / 'state.json').write_text(json.dumps({'records': {'ctx/pkg': {
|
||||
'context':'ctx', 'steamToken':'PRIVATE_BINDING_TOKEN',
|
||||
'metadata': {'package':'test.pkg', 'name':'Test app', 'icon':'PRIVATE_ICON'},
|
||||
'removed':False, 'pending':'uninstall'}}}))
|
||||
for i in range(14):
|
||||
path = apk / 'logs' / f'{i:02}.log'
|
||||
path.write_text(f'operation {i} token=PRIVATE_LOG_TOKEN')
|
||||
import os
|
||||
os.utime(path, (i + 1, i + 1))
|
||||
saved = home / '.local/share/Steam/logs/lepton-logcats/ctx'
|
||||
saved.mkdir(parents=True)
|
||||
(saved / 'logcat-crash.log').write_text('Android boot failure password=PRIVATE_PASSWORD')
|
||||
(saved / 'video.mp4').write_text('PRIVATE_MEDIA')
|
||||
(saved / 'logcat-symlink.log').symlink_to(saved / 'video.mp4')
|
||||
result = diag.collect(root, commands=False, runtime=root/'runtime', steam_home=home)
|
||||
archive = zipfile.ZipFile(io.BytesIO(base64.b64decode(result['data'])))
|
||||
self.assertIn('apk/summary.json', archive.namelist())
|
||||
self.assertIn('apk/android-saved/ctx/logcat-crash.log', archive.namelist())
|
||||
self.assertNotIn('apk/operations/00.log', archive.namelist())
|
||||
self.assertIn('apk/operations/13.log', archive.namelist())
|
||||
text = '\n'.join(archive.read(n).decode() for n in archive.namelist())
|
||||
self.assertIn('Android boot failure', text)
|
||||
self.assertIn('Test app', text)
|
||||
self.assertNotIn('PRIVATE_', text)
|
||||
|
||||
def test_active_container_diagnostics_use_session_user_and_keep_timeout_output(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = pathlib.Path(temp)
|
||||
(root/'steam-user').write_text('teststeam')
|
||||
home = root/'user'
|
||||
user = SimpleNamespace(pw_name='teststeam', pw_dir=str(home), pw_uid=1000)
|
||||
calls = []
|
||||
def run(args, **kwargs):
|
||||
calls.append(args)
|
||||
if 'ps' in args:
|
||||
kwargs['stdout'].write(b'lepton-active\tUp 1 minute\nlepton-stopped\tExited (0)\n')
|
||||
elif 'exec' in args:
|
||||
kwargs['stdout'].write(b'=== boot ===\n0\n=== user ===\nRUNNING_LOCKED\n')
|
||||
raise subprocess.TimeoutExpired(args, 8)
|
||||
return SimpleNamespace(returncode=0)
|
||||
with mock.patch.object(diag.pwd,'getpwnam',return_value=user), mock.patch.object(diag.os,'geteuid',return_value=0), mock.patch.object(diag.subprocess,'run',side_effect=run):
|
||||
result = diag.collect(root, runtime=root/'runtime')
|
||||
archive = zipfile.ZipFile(io.BytesIO(base64.b64decode(result['data'])))
|
||||
text = archive.read('apk/android-live/lepton-active.log').decode()
|
||||
self.assertIn('RUNNING_LOCKED', text)
|
||||
executions = [a for a in calls if 'exec' in a]
|
||||
self.assertEqual(len(executions), 1)
|
||||
self.assertEqual(executions[0][:4], ['runuser','-u','teststeam','--'])
|
||||
self.assertIn('HOME='+str(home), executions[0])
|
||||
self.assertIn('XDG_RUNTIME_DIR=/run/user/1000', executions[0])
|
||||
self.assertNotIn('start', executions[0])
|
||||
self.assertTrue(any('partial output' in w for w in json.loads(archive.read('report.json'))['warnings']))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+112
-8
@@ -8,6 +8,7 @@ import json
|
||||
import os
|
||||
import pathlib
|
||||
import platform
|
||||
import pwd
|
||||
import re
|
||||
import subprocess
|
||||
import zipfile
|
||||
@@ -23,7 +24,7 @@ def redact(text):
|
||||
text = re.sub(r'(/boot/)[a-f0-9]{32,}', r'\1[redacted]', text)
|
||||
return URL_CREDENTIALS.sub(r'\1[redacted]@', text)
|
||||
|
||||
def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_log=None):
|
||||
def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_log=None, steam_home=None):
|
||||
report = {'format': 1, 'createdAt': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'warnings': []}
|
||||
entries = {}
|
||||
budget = MAX_TOTAL
|
||||
@@ -43,7 +44,10 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
|
||||
if path.is_symlink() or not path.is_file():
|
||||
return
|
||||
with path.open('rb') as source:
|
||||
source.seek(max(0, os.fstat(source.fileno()).st_size - MAX_FILE))
|
||||
size = os.fstat(source.fileno()).st_size
|
||||
if size > MAX_FILE:
|
||||
report['warnings'].append(name + ': truncated to recent output')
|
||||
source.seek(max(0, size - MAX_FILE))
|
||||
add(name, source.read(MAX_FILE).decode('utf-8', errors='replace'))
|
||||
except (OSError, ValueError) as error:
|
||||
report['warnings'].append(name + ': ' + str(error))
|
||||
@@ -52,12 +56,22 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
|
||||
# Temporary files avoid buffering unbounded journal output in memory.
|
||||
import tempfile
|
||||
with tempfile.TemporaryFile() as output:
|
||||
result = subprocess.run(args, stdout=output, stderr=subprocess.STDOUT, timeout=8, check=False)
|
||||
try:
|
||||
result = subprocess.run(args, stdout=output, stderr=subprocess.STDOUT, timeout=8, check=False)
|
||||
code = result.returncode
|
||||
except subprocess.TimeoutExpired:
|
||||
code = None
|
||||
report['warnings'].append(name + ': command timed out; partial output included')
|
||||
output.seek(0, 2)
|
||||
output.seek(max(0, output.tell() - MAX_FILE))
|
||||
add(name, output.read(MAX_FILE).decode('utf-8', errors='replace'))
|
||||
if result.returncode:
|
||||
report['warnings'].append(name + ': command exited ' + str(result.returncode))
|
||||
size = output.tell()
|
||||
if size > MAX_FILE:
|
||||
report['warnings'].append(name + ': truncated to recent output')
|
||||
output.seek(max(0, size - MAX_FILE))
|
||||
text = output.read(MAX_FILE).decode('utf-8', errors='replace')
|
||||
add(name, text)
|
||||
if code:
|
||||
report['warnings'].append(name + ': command exited ' + str(code))
|
||||
return text if code == 0 else None
|
||||
except (OSError, subprocess.TimeoutExpired) as error:
|
||||
report['warnings'].append(name + ': ' + str(error))
|
||||
if installer_log is not None:
|
||||
@@ -77,6 +91,96 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
|
||||
]}, ensure_ascii=False, indent=2))
|
||||
except (OSError, ValueError, TypeError, AttributeError) as error:
|
||||
report['warnings'].append('plugins.json: ' + str(error))
|
||||
# APK management runs as the configured Steam user, not the root daemon.
|
||||
# Resolve that user explicitly: root's HOME and rootful Podman are wrong.
|
||||
steam_user = None
|
||||
try:
|
||||
if steam_home is None:
|
||||
user_file = state / 'steam-user'
|
||||
if user_file.is_file() and not user_file.is_symlink():
|
||||
name = user_file.read_text()[:256].strip()
|
||||
steam_user = pwd.getpwnam(name)
|
||||
elif os.geteuid() != 0:
|
||||
steam_user = pwd.getpwuid(os.geteuid())
|
||||
if steam_user is not None and steam_user.pw_uid >= 1000:
|
||||
steam_home = pathlib.Path(steam_user.pw_dir)
|
||||
if steam_home is None:
|
||||
report['warnings'].append('apk: Steam session user unavailable; APK logs not collected')
|
||||
else:
|
||||
steam_home = pathlib.Path(steam_home)
|
||||
apk = steam_home / '.local/share/framely/apk-manager'
|
||||
try:
|
||||
with (apk / 'state.json').open('rb') as source:
|
||||
raw = source.read(16 * 1024 * 1024 + 1)
|
||||
if len(raw) > 16 * 1024 * 1024:
|
||||
raise ValueError('APK state exceeds diagnostic size limit')
|
||||
database = json.loads(raw)
|
||||
records = database.get('records', {})
|
||||
# Never include tokens, bindings, icons or the complete state.
|
||||
summary = {'apps': [
|
||||
{'id': key, 'context': item.get('context'), 'removed': item.get('removed'),
|
||||
'pending': item.get('pending'), 'steamAppId': item.get('steamAppId'),
|
||||
'steamLaunch': item.get('steamLaunch'),
|
||||
'package': item.get('metadata', {}).get('package'),
|
||||
'name': item.get('metadata', {}).get('name'),
|
||||
'version': item.get('metadata', {}).get('version')}
|
||||
for key, item in records.items()
|
||||
], 'gamepadEnabled': database.get('gamepad_enabled'),
|
||||
'gamepadSource': database.get('gamepad_source')}
|
||||
add('apk/summary.json', json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
except (OSError, ValueError, TypeError, AttributeError) as error:
|
||||
report['warnings'].append('apk/summary.json: ' + str(error))
|
||||
|
||||
def recent_logs(directory, pattern, limit, prefix):
|
||||
try:
|
||||
if directory.is_symlink():
|
||||
raise ValueError('Diagnostic log directory must not be a symlink')
|
||||
if not directory.is_dir():
|
||||
report['warnings'].append(prefix + ': log directory unavailable')
|
||||
return
|
||||
paths = [p for p in directory.glob(pattern)
|
||||
if not p.is_symlink() and not p.parent.is_symlink() and p.is_file()]
|
||||
paths.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
for path in paths[:limit]:
|
||||
tail(path, prefix + '/' + path.relative_to(directory).as_posix())
|
||||
if len(paths) > limit:
|
||||
report['warnings'].append(prefix + ': only the ' + str(limit) + ' newest logs included')
|
||||
except (OSError, ValueError) as error:
|
||||
report['warnings'].append(prefix + ': ' + str(error))
|
||||
|
||||
recent_logs(apk / 'logs', '*.log', 12, 'apk/operations')
|
||||
steam = steam_home / '.local/share/Steam'
|
||||
if not steam.is_dir():
|
||||
steam = steam_home / '.steam/steam'
|
||||
# Only known diagnostic files, never media, APKs, saves or tombstones.
|
||||
recent_logs(steam / 'logs/lepton-logcats', '*/logcat-*.log', 12, 'apk/android-saved')
|
||||
if commands and steam_user is not None:
|
||||
prefix = []
|
||||
if os.geteuid() == 0:
|
||||
prefix = ['runuser', '-u', steam_user.pw_name, '--']
|
||||
prefix += ['env', 'HOME=' + str(steam_home),
|
||||
'XDG_RUNTIME_DIR=/run/user/' + str(steam_user.pw_uid), 'podman']
|
||||
listing = command('apk/containers.txt', prefix + ['ps', '-a', '--filter', 'name=^lepton-', '--format', '{{.Names}}\t{{.Status}}'])
|
||||
if listing:
|
||||
active = []
|
||||
for line in listing.splitlines():
|
||||
name, _, status = line.partition('\t')
|
||||
if re.fullmatch(r'lepton-[A-Za-z0-9_.-]+', name) and status.startswith('Up '):
|
||||
active.append(name)
|
||||
for name in active[:4]:
|
||||
# A single read-only exec per active container bounds export
|
||||
# latency and works even when PackageManager is not ready.
|
||||
probe = ('echo "=== boot ==="; getprop sys.boot_completed; '
|
||||
'echo "=== user ==="; am get-started-user-state 0; '
|
||||
'echo "=== storage ==="; ls -ld /storage/emulated/0; dumpsys mount; '
|
||||
'echo "=== package manager ==="; pm path android; '
|
||||
'echo "=== recent Android logs ==="; logcat -d -b main -b system -b crash -t 500')
|
||||
command('apk/android-live/' + name + '.log', prefix + ['exec', name, 'sh', '-c', probe])
|
||||
if len(active) > 4:
|
||||
report['warnings'].append('apk/android-live: only four active containers queried')
|
||||
except (OSError, ValueError, KeyError) as error:
|
||||
report['warnings'].append('apk: ' + str(error))
|
||||
|
||||
try:
|
||||
for path in sorted((state / 'logs').glob('*.log'))[:64]:
|
||||
tail(path, 'logs/' + path.name)
|
||||
@@ -114,7 +218,7 @@ def collect(state, commands=True, runtime=pathlib.Path('/run/user'), installer_l
|
||||
if commands:
|
||||
command('services.txt', ['systemctl', 'status', 'framely.service', 'framely-session.service', '--no-pager', '--full'])
|
||||
command('journal.txt', ['journalctl', '-u', 'framely.service', '-u', 'framely-session.service', '-u', 'framely-plugin-*', '-u', 'framely-backend-*', '-u', 'framely-hook-*', '--since=-24h', '-n', '3000', '--no-pager', '-o', 'short-iso'])
|
||||
add('README.txt', 'Framely diagnostics. Recent log tails, service state and installed version summary only. Missing sources are listed in report.json. Logs may contain application data; inspect before sharing.\n')
|
||||
add('README.txt', 'Framely diagnostics. Recent Framely and APK operation logs, saved/active Lepton Android log tails, boot/unlock/storage diagnostics, service state and installed version summary only. Application files, media, APK archives and raw memory dumps are excluded. Missing sources are listed in report.json. Logs may contain application data; inspect before sharing.\n')
|
||||
entries['report.json'] = json.dumps(report, ensure_ascii=False, indent=2).encode()
|
||||
memory = io.BytesIO()
|
||||
with zipfile.ZipFile(memory, 'w', zipfile.ZIP_DEFLATED) as archive:
|
||||
|
||||
@@ -499,7 +499,6 @@
|
||||
"日志已保存到:{0}": "Logs saved to: {0}",
|
||||
"日志包已生成,请查看浏览器下载。": "Log archive created. Check your browser downloads.",
|
||||
"日志包缺少数据": "Log archive data is missing",
|
||||
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "Export Framely, plugin and renderer logs with service status. Plugin settings and access password files are excluded.",
|
||||
"发现可用发行版本": "Release available",
|
||||
"稍后": "Later",
|
||||
"全部包含的分类": "Categories included in All",
|
||||
@@ -968,6 +967,7 @@
|
||||
"振动反馈": "Haptic feedback",
|
||||
"提示音反馈": "Sound feedback",
|
||||
"指向可交互控件时振动,不影响 APK 游戏振动。": "Vibrate when pointing at interactive controls. APK game rumble is unaffected.",
|
||||
"使用 Steam 提示音反馈聚焦和点击。": "Use Steam sounds for focus and clicks."
|
||||
"使用 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."
|
||||
}
|
||||
}
|
||||
@@ -501,7 +501,6 @@
|
||||
"日志已保存到:{0}": "日志已保存到:{0}",
|
||||
"日志包已生成,请查看浏览器下载。": "日志包已生成,请查看浏览器下载。",
|
||||
"日志包缺少数据": "日志包缺少数据",
|
||||
"导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。": "导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。",
|
||||
"发现可用发行版本": "发现可用发行版本",
|
||||
"稍后": "稍后",
|
||||
"全部包含的分类": "全部包含的分类",
|
||||
@@ -958,6 +957,7 @@
|
||||
"振动反馈": "振动反馈",
|
||||
"提示音反馈": "提示音反馈",
|
||||
"指向可交互控件时振动,不影响 APK 游戏振动。": "指向可交互控件时振动,不影响 APK 游戏振动。",
|
||||
"使用 Steam 提示音反馈聚焦和点击。": "使用 Steam 提示音反馈聚焦和点击。"
|
||||
"使用 Steam 提示音反馈聚焦和点击。": "使用 Steam 提示音反馈聚焦和点击。",
|
||||
"导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。": "导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。"
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -112,7 +112,7 @@ function App(){
|
||||
{tab==='sources'&&managerMode&&<><div className="list-heading"><div><h1>{t("插件源")}</h1><p>{t("管理插件目录及订阅。")}</p></div></div><section className="settings-card"><Sources origins={status.database.sourceOrigins} sources={status.database.sources} save={async s=>{await api('sources.save',{sources:s});await refresh();}}/></section><Subscriptions subscriptions={status.database.subscriptions??[]} refresh={refresh}/></>}
|
||||
{tab==='updates'&&managerMode&&<SystemUpdates status={status} refresh={refresh}/>}
|
||||
{tab==='about'&&managerMode&&<><div className="list-heading"><div><h1>{t("关于 Framely")}</h1></div></div><div className="about"><Brand/><b>Framely</b><small>{status.build??status.version}</small></div><section className="settings-card"><h2>{t("用户协议与隐私声明")}</h2><div className="row"><button onClick={()=>setReviewAgreement(true)}>{t("查看协议与隐私声明")}</button></div></section></>}
|
||||
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/></section><LanguageSettings selectionOnly value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><button className="setting-entry quick-update-entry" onClick={()=>void action(()=>api('host.manager.open',{page:'updates'}))}><span className="setting-symbol"><Icon name="refresh" size={28}/></span><span><b>{t("检查更新")}</b></span><span className="forward">›</span></button><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><section className="settings-card"><h2>{t("交互反馈")}</h2>{(['haptics','sounds'] as const).map(key=><SwitchRow key={key} label={t(key==='haptics'?'振动反馈':'提示音反馈')} description={t(key==='haptics'?'指向可交互控件时振动,不影响 APK 游戏振动。':'使用 Steam 提示音反馈聚焦和点击。')} checked={status.database.feedback?.[key]??true} disabled={busy} onChange={enabled=>void action(()=>api('feedback.settings.save',{haptics:true,sounds:true,...status.database.feedback,[key]:enabled}))}/>)}</section><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/><p className="sub">{t("导出本体、插件和渲染日志及服务状态,不包含插件配置和访问密码文件。")}</p><div className="row"><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button><button disabled={exportingLogs||busy} onClick={()=>void exportLogs()}>{exportingLogs?t("正在导出日志…"):t("导出日志")}</button></div>{logExport&&<p role="status">{logExport}</p>}</section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
|
||||
{tab==='settings'&&(!managerMode?<div className="quick-settings"><button className="setting-entry" onClick={()=>void api('host.manager.open')}><span className="setting-symbol"><Icon name="plugins" size={28}/></span><span><b>{t("管理插件")}</b><small>{t("安装、更新与管理插件源")}</small></span><span className="forward">›</span></button><section className="settings-card"><h2>{t("运行模式")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/></section><LanguageSettings selectionOnly value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><button className="setting-entry quick-update-entry" onClick={()=>void action(()=>api('host.manager.open',{page:'updates'}))}><span className="setting-symbol"><Icon name="refresh" size={28}/></span><span><b>{t("检查更新")}</b></span><span className="forward">›</span></button><div className="about"><Brand/><b>Framely</b><small>{t("版本")}{status.build??status.version}</small></div></div>:<><div className="list-heading"><div><h1>{t("设置")}</h1><p>{t("运行模式与诊断。")}</p></div></div><LanguageSettings value={status.database.language??'auto'} packs={localePacks.current??[]} invalidFiles={languageFiles} refresh={refreshLanguages}/><NetworkSettings config={status.database.networkPanel??{enabled:true,port:15915}} refresh={refresh}/><section className="settings-card"><h2>{t("交互反馈")}</h2>{(['haptics','sounds'] as const).map(key=><SwitchRow key={key} label={t(key==='haptics'?'振动反馈':'提示音反馈')} description={t(key==='haptics'?'指向可交互控件时振动,不影响 APK 游戏振动。':'使用 Steam 提示音反馈聚焦和点击。')} checked={status.database.feedback?.[key]??true} disabled={busy} onChange={enabled=>void action(()=>api('feedback.settings.save',{haptics:true,sounds:true,...status.database.feedback,[key]:enabled}))}/>)}</section><ProxySettings config={status.database.proxy??{http:"",github:""}} refresh={refresh}/><section className="settings-card"><h2>{t("恢复与诊断")}</h2><SwitchRow label={t("安全模式")} description={t("暂停所有第三方插件")} checked={status.database.safeMode} disabled={busy} onChange={enabled=>void action(()=>api('safeMode',{enabled}))}/><p className="sub">{t("导出本体、插件、渲染及 APK/Lepton 日志和服务状态,不包含插件配置和访问密码文件。")}</p><div className="row"><button onClick={()=>void action(async()=>setLogs(JSON.stringify(await api('status'),null,2)))}>{t("诊断信息")}</button><button disabled={exportingLogs||busy} onClick={()=>void exportLogs()}>{exportingLogs?t("正在导出日志…"):t("导出日志")}</button></div>{logExport&&<p role="status">{logExport}</p>}</section><section className="settings-card"><h2>{t("运行用户")}</h2><p className="sub">{t("steamos 使用 Steam 会话用户运行;root 使用系统管理员运行。安装前请确认插件来源和运行用户。")}</p></section></>)}
|
||||
{tab==='catalog'&&<Store target={catalogTarget} plugins={status.database.plugins} sources={status.database.sources} onInspect={inspect} onSources={()=>setTab('sources')} busy={busy}/>}
|
||||
</main></div>}
|
||||
{importing&&<div className="modal" role="dialog" aria-modal="true" aria-label={t("安装插件")}><button className="icon-button modal-close" aria-label={t("关闭导入")} onClick={()=>setImporting(false)}><Icon name="close"/></button><InstallInput inspect={async p=>{await inspect(p);setImporting(false);}} busy={busy}/></div>}
|
||||
|
||||
Reference in new issue
Block a user