dsh-ios-webview-app-control 0.0.0-stage → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/LICENSE +21 -0
- package/README.md +250 -2
- package/THIRD_PARTY_NOTICES.md +42 -0
- package/cordis.patch.yml +20 -0
- package/docs/installation.md +68 -0
- package/docs/new-session-prompt.md +29 -0
- package/docs/runtime-environment.md +40 -0
- package/docs/sdk-contract.md +79 -0
- package/docs/verification.md +64 -0
- package/icon.svg +8 -0
- package/lib/cli.mjs +51 -0
- package/lib/client.js +223 -0
- package/lib/core/dom.mjs +26 -0
- package/lib/core/leases.mjs +31 -0
- package/lib/core/protocol.mjs +101 -0
- package/lib/core/python.mjs +47 -0
- package/lib/core/service.mjs +122 -0
- package/lib/host/environment-service.mjs +175 -0
- package/lib/host/subprocess-runner.mjs +34 -0
- package/lib/plugin.mjs +303 -0
- package/lib/runtime/environment-manager.mjs +544 -0
- package/lib/runtime/errors.mjs +33 -0
- package/lib/runtime/runner.mjs +75 -0
- package/lib/runtime/safe-tar.mjs +179 -0
- package/lib/typert-environment.mjs +103 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/requirements.txt +1 -0
- package/resources/notices/PBS-LICENSE.bdb.txt +126 -0
- package/resources/notices/PBS-LICENSE.bzip2.txt +37 -0
- package/resources/notices/PBS-LICENSE.cpython.txt +771 -0
- package/resources/notices/PBS-LICENSE.expat.txt +21 -0
- package/resources/notices/PBS-LICENSE.libX11.txt +942 -0
- package/resources/notices/PBS-LICENSE.libXau.txt +21 -0
- package/resources/notices/PBS-LICENSE.libedit.txt +29 -0
- package/resources/notices/PBS-LICENSE.libffi.txt +21 -0
- package/resources/notices/PBS-LICENSE.liblzma.txt +13 -0
- package/resources/notices/PBS-LICENSE.libuuid.txt +27 -0
- package/resources/notices/PBS-LICENSE.libxcb.txt +30 -0
- package/resources/notices/PBS-LICENSE.mpdecimal.txt +24 -0
- package/resources/notices/PBS-LICENSE.ncurses.txt +29 -0
- package/resources/notices/PBS-LICENSE.openssl-3.txt +177 -0
- package/resources/notices/PBS-LICENSE.sqlite.txt +23 -0
- package/resources/notices/PBS-LICENSE.tcl.txt +40 -0
- package/resources/notices/PBS-LICENSE.tix.txt +54 -0
- package/resources/notices/PBS-LICENSE.zlib-ng.txt +19 -0
- package/resources/notices/PBS-LICENSE.zlib.txt +21 -0
- package/resources/notices/PBS-LICENSE.zstd.txt +30 -0
- package/resources/notices/PYMOBILEDEVICE3-LICENSE.txt +674 -0
- package/resources/notices/PYTHON-LICENSE.txt +277 -0
- package/resources/notices/provenance.json +134 -0
- package/resources/runtime-lock.json +4252 -0
- package/scripts/manage-desktop.ps1 +56 -0
- package/scripts/package-validation.mjs +223 -0
- package/scripts/verify-package.mjs +13 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# 验证说明
|
|
2
|
+
|
|
3
|
+
这里说明怎么验证本插件,什么结果算通过。某次开发验证的原始记录放在文末。
|
|
4
|
+
|
|
5
|
+
## 验证范围与边界
|
|
6
|
+
|
|
7
|
+
- 本插件只描述自己的 `ios_*` 工具能力;不是 DSH 会话的完整工具清单。
|
|
8
|
+
- 验证不得访问未授权设备;自动测试使用隔离 Context、内存 Session 与假设备派发。
|
|
9
|
+
- 本验证不覆盖其它平台工具、真实手机业务、私有 Python 环境完整性或 UI 视觉渲染。
|
|
10
|
+
|
|
11
|
+
## 自动验证
|
|
12
|
+
|
|
13
|
+
在项目根目录执行下面这些命令。
|
|
14
|
+
|
|
15
|
+
```powershell
|
|
16
|
+
npm run build
|
|
17
|
+
npm run check
|
|
18
|
+
npm test
|
|
19
|
+
New-Item -ItemType Directory -Path artifacts -Force | Out-Null
|
|
20
|
+
npm pack --pack-destination artifacts
|
|
21
|
+
node scripts/verify-package.mjs
|
|
22
|
+
node scripts/smoke-package.mjs
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
什么算通过。
|
|
26
|
+
|
|
27
|
+
- build 从 `src` 生成 `lib`,顺便做 Node 语法检查。
|
|
28
|
+
- check 校验包入口、bundle 配置、运行资源锁、中英文元数据、图标及文档链接。
|
|
29
|
+
- test 覆盖 ToolRuntime/SystemPrompt/ApprovalService/Session 服务链、Typert Gateway/Client、环境管理、作用域专项、核心与租约、归档校验。全部通过,没有 skip 才算过。
|
|
30
|
+
- verify-package 校验归档 version/engines/peers、全部 lib 字节、Client、locale、资源锁与许可原文一致。
|
|
31
|
+
- smoke-package 从 tgz 实际导入、注册 13 个工具、拒绝 Flutter 与 never、清理完成,不访问设备。
|
|
32
|
+
|
|
33
|
+
## 作用域专项回归
|
|
34
|
+
|
|
35
|
+
- `ios_capabilities` 含 `source`、`scope=plugin-only`、`inventory=not-session-inventory`;`android`、`nativeTap` 等保持 false。
|
|
36
|
+
- 独立注册的 fixture 工具不会因本插件 false 被隐藏,也不能被当作已授权。
|
|
37
|
+
- Android/native/Flutter/unknown 仍在本插件入口拒绝;审批与设备 I/O 为零。
|
|
38
|
+
- 审批拒绝后不能通过 shell、CLI、核心导入等改道执行同一操作。
|
|
39
|
+
- CLI、connect 内嵌能力、UI 文案与文档表达同一作用域。
|
|
40
|
+
|
|
41
|
+
## 运行时验收
|
|
42
|
+
|
|
43
|
+
- Config 查询存在 `include:ios-webview-app-control`,且 schema 可用。
|
|
44
|
+
- Tool Inspect 显示 13 个工具,`ios_capabilities` 的描述要写清「不是会话能力清单或连接检查」。
|
|
45
|
+
- 真实调用 `ios_capabilities` 返回新作用域字段。旧进程或旧 Agent 可能还在返回旧模块结果,要完整重启后再验一次。
|
|
46
|
+
- `applied`、`enabled`、fiber `active` 都不能替代实际工具调用与描述核对。
|
|
47
|
+
|
|
48
|
+
## 未覆盖项
|
|
49
|
+
|
|
50
|
+
- 未访问或操作真实 iOS/Android 手机;未证明目标技术类型、UDID、App 身份、USB 连接或业务结果。
|
|
51
|
+
- 未调查其它会话工具;不能从本插件 scope 标记推断外部工具存在或缺失。
|
|
52
|
+
- UI/图标视觉渲染未验收;Client 仅通过隔离注册与测试核对。
|
|
53
|
+
- 旧会话历史中的错误结论不会因升级自动消失。
|
|
54
|
+
|
|
55
|
+
## 已验证环境(仅开发环境快照)
|
|
56
|
+
|
|
57
|
+
这是某次开发验证的快照,不是兼容承诺,也不是运行前提。
|
|
58
|
+
|
|
59
|
+
- 2026-10-31,Windows x64;Node 22.18.0;DSH 0.2.1-rc.5;Cordis 4.0.5;Schemastery 3.18.5。
|
|
60
|
+
- 0.2.1 归档 57 文件、163,988 字节,SHA256 `e62eae32011c1d394bbc35e94b805ceb39c55f162dbb080facb02bcf9d73d138`;204 项测试通过(0 failed,0 skipped);build、check、pack、verify-package、smoke 均通过。
|
|
61
|
+
- 修订前 GUIDANCE + 13 工具 schema 为 13,424 UTF-8 字节,修订后 13,387 字节(按 bytes/4 粗估 3,356 → 3,347,不是供应商 tokenizer)。
|
|
62
|
+
- 完整重启前真实工具与描述仍为旧内容;重启后 `ios_capabilities` 返回 scope/inventory,新描述生效。该对照说明 `applied` 不等于当前 Agent 已加载新说明。
|
|
63
|
+
- 该快照中私有 Python 环境准备未完成(下载阶段 `runtime_timeout`),不作为已验收项。
|
|
64
|
+
- 0.2.0 的 199 项回归和安装是上一版本历史,不能冒充 0.2.1 的说明/运行时验收。
|
package/icon.svg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="iOS WebView App Control">
|
|
2
|
+
<!-- Original artwork, distributed under the package MIT license. -->
|
|
3
|
+
<rect width="64" height="64" rx="16" fill="#163A59"/>
|
|
4
|
+
<rect x="17" y="7" width="30" height="50" rx="7" fill="#E7F5FB"/>
|
|
5
|
+
<rect x="21" y="16" width="22" height="29" rx="3" fill="#226A8C"/>
|
|
6
|
+
<path d="m28 24-5 6 5 6m8-12 5 6-5 6m-3-14-3 16" fill="none" stroke="#BFF8E8" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
7
|
+
<path d="M28 11h8m-6 40h4" fill="none" stroke="#163A59" stroke-width="2.5" stroke-linecap="round"/>
|
|
8
|
+
</svg>
|
package/lib/cli.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {IosControl} from './core/service.mjs';
|
|
3
|
+
import {EnvironmentManager} from './runtime/environment-manager.mjs';
|
|
4
|
+
const command=process.argv[2]||'help';
|
|
5
|
+
const print=value=>console.log(JSON.stringify(value,null,2));
|
|
6
|
+
if(command==='capabilities'){
|
|
7
|
+
const service=new IosControl();
|
|
8
|
+
try{print(await service.execute('capabilities'));}finally{await service.dispose();}
|
|
9
|
+
}else if(command==='environment'){
|
|
10
|
+
const action=process.argv[3]||'status';
|
|
11
|
+
const options=process.argv.slice(4);
|
|
12
|
+
const allowed=new Set(['--directory','--python','--offline','--private','--yes']);
|
|
13
|
+
const config={runtime:{}};let forcePrivate=false,confirmed=false;
|
|
14
|
+
for(let index=0;index<options.length;index++){
|
|
15
|
+
const key=options[index];if(!allowed.has(key))throw Error('Unknown environment option: '+key);
|
|
16
|
+
if(key==='--directory'||key==='--python'){
|
|
17
|
+
const value=options[++index];if(!value||value.startsWith('--'))throw Error('Missing option value: '+key);
|
|
18
|
+
if(key==='--directory')config.runtime.directory=value;else config.pythonPath=value;
|
|
19
|
+
}else if(key==='--offline')config.runtime.offline=true;
|
|
20
|
+
else if(key==='--private')forcePrivate=true;
|
|
21
|
+
else if(key==='--yes')confirmed=true;
|
|
22
|
+
}
|
|
23
|
+
if(!['status','prepare','cleanup'].includes(action))throw Error('Environment CLI supports status, prepare and cleanup only.');
|
|
24
|
+
if(action==='cleanup'&&!confirmed)throw Error('Cleanup requires --yes and removes unused plugin-owned runtime resources only.');
|
|
25
|
+
const environment=new EnvironmentManager(config);
|
|
26
|
+
const controller=new AbortController();
|
|
27
|
+
const cancel=()=>{controller.abort();environment.cancel();};
|
|
28
|
+
process.once('SIGINT',cancel);process.once('SIGTERM',cancel);
|
|
29
|
+
try{
|
|
30
|
+
if(action==='prepare'){
|
|
31
|
+
let previous='';
|
|
32
|
+
environment.subscribe(state=>{
|
|
33
|
+
const line=[state.phase,state.step].filter(Boolean).join(': ');
|
|
34
|
+
if(line!==previous){process.stderr.write(line+'\n');previous=line;}
|
|
35
|
+
});
|
|
36
|
+
print(await environment.prepare({signal:controller.signal,forcePrivate}));
|
|
37
|
+
}else if(action==='cleanup')print(await environment.cleanup({signal:controller.signal}));
|
|
38
|
+
else print(environment.status());
|
|
39
|
+
}catch(error){
|
|
40
|
+
print({ok:false,error:{code:error.code||'environment_error',message:error.message},state:environment.status()});
|
|
41
|
+
process.exitCode=1;
|
|
42
|
+
}finally{
|
|
43
|
+
process.removeListener('SIGINT',cancel);process.removeListener('SIGTERM',cancel);
|
|
44
|
+
await environment.dispose();
|
|
45
|
+
}
|
|
46
|
+
}else if(command==='help'){
|
|
47
|
+
console.log('dsh-ios-webview-app-control capabilities\ndsh-ios-webview-app-control environment status\ndsh-ios-webview-app-control environment prepare [--python ABSOLUTE_PATH] [--directory ABSOLUTE_PATH] [--offline] [--private]\ndsh-ios-webview-app-control environment cleanup --yes [--directory ABSOLUTE_PATH]\nEnvironment preparation installs only private dependencies. Device operations remain DSH approval-gated. This plugin handles only iOS WebView/hybrid targets. Its capabilities are plugin-only, not a DSH/session tool inventory; independently inspect other available tools and their permissions for other tasks.');
|
|
48
|
+
}else{
|
|
49
|
+
console.error('Unsupported CLI operation. Use DSH plugin tools with per-call approval for device access.');
|
|
50
|
+
process.exitCode=1;
|
|
51
|
+
}
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-ios-webview-app-control',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react');
|
|
5
|
+
const { z } = require('zod');
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
const PACKAGE = 'dsh-ios-webview-app-control';
|
|
8
|
+
const NS = 'ios-environment';
|
|
9
|
+
const PHASES = ['unprepared', 'checking', 'downloading', 'creating', 'installing', 'verifying', 'ready', 'failed', 'cancelled'];
|
|
10
|
+
// Own, hand-authored Host-for-Client contribution. No Harness package imports.
|
|
11
|
+
const snapshotSchema = z.strictObject({
|
|
12
|
+
phase: z.enum(PHASES), busy: z.boolean(), step: z.string().max(256),
|
|
13
|
+
progress: z.strictObject({
|
|
14
|
+
completedBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
|
15
|
+
totalBytes: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
|
|
16
|
+
}).nullable().optional(),
|
|
17
|
+
python: z.strictObject({
|
|
18
|
+
path: z.string().max(8192), version: z.string().max(128), source: z.enum(['host', 'private']), abi: z.string().max(128).optional(),
|
|
19
|
+
}).nullable().optional(),
|
|
20
|
+
runtimeDir: z.string().max(8192).optional(), pymobiledevice3: z.string().max(128).optional(),
|
|
21
|
+
error: z.strictObject({ code: z.string().max(128), message: z.string().max(2048) }).nullable().optional(),
|
|
22
|
+
});
|
|
23
|
+
const codec = Object.freeze({
|
|
24
|
+
mode: 'strict', typeSymbol: `${PACKAGE}#EnvironmentSnapshot`,
|
|
25
|
+
create: () => snapshotSchema,
|
|
26
|
+
decode: value => snapshotSchema.parse(value),
|
|
27
|
+
encode: value => snapshotSchema.parse(value),
|
|
28
|
+
});
|
|
29
|
+
const remoteContribution = Object.freeze({
|
|
30
|
+
package: PACKAGE,
|
|
31
|
+
descriptors: Object.freeze(['status', 'prepare', 'cancel', 'cleanup', 'follow'].map(method => Object.freeze({
|
|
32
|
+
id: `${PACKAGE}#iosEnvironment/${method}`, service: 'iosEnvironment', namespace: 'iosEnvironment', method,
|
|
33
|
+
invocation: Object.freeze({ kind: 'direct' }), parameters: Object.freeze([]),
|
|
34
|
+
cancellation: Object.freeze({ parameter: 'signal' }),
|
|
35
|
+
...(method === 'follow' ? { mode: 'stream' } : {}), result: codec,
|
|
36
|
+
}))),
|
|
37
|
+
});
|
|
38
|
+
const dictionaries = {
|
|
39
|
+
zh: {
|
|
40
|
+
tab: 'iOS 环境', title: 'iOS WebView 环境', activationTitle: '准备 iOS WebView 运行环境',
|
|
41
|
+
intro: '只准备本机 Python 与固定版本依赖;不会发现、配对或连接手机,也不会启动桥接。',
|
|
42
|
+
boundary: '本插件仅提供 iOS WebView / 混合应用控制,不提供原生、Flutter、React Native 或 Android 控制;这不是 DSH 会话的完整工具清单。其它工具需独立核对能力和权限;本插件的设备操作仍逐次审批。',
|
|
43
|
+
loading: '正在读取环境状态…', source: '解释器来源', sourceHost: '宿主 Python', sourcePrivate: '插件私有 Python',
|
|
44
|
+
python: 'Python 版本', path: '解释器路径', abi: 'ABI', runtimeDir: '私有环境目录', dependency: 'pymobiledevice3',
|
|
45
|
+
missing: '尚未准备', phase: '当前阶段', progress: '下载进度', bytes: '{completed} / {total} 字节', bytesUnknown: '已下载 {completed} 字节',
|
|
46
|
+
prepare: '准备环境', retry: '重试准备', cancel: '取消准备', cleanup: '清理闲置环境', refresh: '刷新状态',
|
|
47
|
+
cleanupTitle: '确认清理闲置环境?', cleanupBody: '仅删除本插件管理的未使用旧环境与闲置下载缓存,保留当前活跃环境。不会卸载宿主 Python、删除设备证据或停止外部桥接。',
|
|
48
|
+
cleanupConfirm: '确认清理', back: '保留环境', dismiss: '关闭', details: '查看插件详情',
|
|
49
|
+
failure: '环境操作失败', followFailure: '状态订阅中断,请刷新状态后重试。', ownerRequired: '只有已认证的本机所有者界面可以管理环境;当前请求未获得此权限。',
|
|
50
|
+
step: '步骤', errorCode: '错误代码', working: '正在处理…', readyHint: '本机环境已就绪;这不表示任何设备或 App 已连接、已授权。',
|
|
51
|
+
'phase.unprepared': '未准备', 'phase.checking': '检查宿主解释器', 'phase.downloading': '下载 Python / 依赖',
|
|
52
|
+
'phase.creating': '创建私有环境', 'phase.installing': '安装固定依赖', 'phase.verifying': '验证环境',
|
|
53
|
+
'phase.ready': '就绪', 'phase.failed': '准备失败', 'phase.cancelled': '已取消',
|
|
54
|
+
},
|
|
55
|
+
en: {
|
|
56
|
+
tab: 'iOS environment', title: 'iOS WebView environment', activationTitle: 'Prepare the iOS WebView environment',
|
|
57
|
+
intro: 'Prepares local Python and pinned dependencies only. Does not discover, pair, or connect to a phone, or start a bridge.',
|
|
58
|
+
boundary: 'This plugin provides iOS WebView / hybrid control only, not native, Flutter, React Native or Android control. This is not a DSH/session tool inventory. Inspect independent tools and their permissions separately; device operations in this plugin still require individual approval.',
|
|
59
|
+
loading: 'Reading environment status…', source: 'Interpreter source', sourceHost: 'Host Python', sourcePrivate: 'Plugin-private Python',
|
|
60
|
+
python: 'Python version', path: 'Interpreter path', abi: 'ABI', runtimeDir: 'Private environment directory', dependency: 'pymobiledevice3',
|
|
61
|
+
missing: 'Not prepared', phase: 'Current phase', progress: 'Download progress', bytes: '{completed} / {total} bytes', bytesUnknown: '{completed} bytes downloaded',
|
|
62
|
+
prepare: 'Prepare environment', retry: 'Retry preparation', cancel: 'Cancel preparation', cleanup: 'Clean up unused environments', refresh: 'Refresh status',
|
|
63
|
+
cleanupTitle: 'Clean up unused environments?', cleanupBody: 'Deletes only unused old environments and idle download caches managed by this plugin, retaining the active environment. Does not uninstall host Python, remove device evidence, or stop an external bridge.',
|
|
64
|
+
cleanupConfirm: 'Confirm cleanup', back: 'Keep environment', dismiss: 'Close', details: 'Plugin details',
|
|
65
|
+
failure: 'Environment operation failed', followFailure: 'Status subscription ended. Refresh the status and retry.', ownerRequired: 'Only an authenticated owner UI can manage this environment. This request has no such authority.',
|
|
66
|
+
step: 'Step', errorCode: 'Error code', working: 'Working…', readyHint: 'The local environment is ready. No device or app is connected or authorized by this state.',
|
|
67
|
+
'phase.unprepared': 'Not prepared', 'phase.checking': 'Checking host interpreter', 'phase.downloading': 'Downloading Python / dependencies',
|
|
68
|
+
'phase.creating': 'Creating private environment', 'phase.installing': 'Installing pinned dependencies', 'phase.verifying': 'Verifying environment',
|
|
69
|
+
'phase.ready': 'Ready', 'phase.failed': 'Preparation failed', 'phase.cancelled': 'Cancelled',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
// Native settings row spacing / typography; only Host light/dark tokens.
|
|
73
|
+
const css = `
|
|
74
|
+
.iosenv-root{max-width:760px;min-width:0;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px;display:flex;flex-direction:column;gap:12px}
|
|
75
|
+
.iosenv-root h3{font-size:16px;line-height:24px;font-weight:600;margin:0}.iosenv-root p{margin:0}.iosenv-muted{color:var(--dsw-alias-label-secondary)}
|
|
76
|
+
.iosenv-state{background:var(--dsw-alias-bg-layer-1);border:.5px solid var(--dsw-alias-border-l2);border-radius:8px;padding:14px 16px;display:flex;flex-direction:column;gap:8px}
|
|
77
|
+
.iosenv-statehead{display:flex;align-items:center;justify-content:space-between;gap:12px}.iosenv-phase{font-size:14px;font-weight:500}.iosenv-rows{margin:0;display:flex;flex-direction:column}
|
|
78
|
+
.iosenv-row{display:grid;grid-template-columns:minmax(100px,150px) minmax(0,1fr);gap:12px;padding:10px 0;border-bottom:.5px solid var(--dsw-alias-border-l2)}
|
|
79
|
+
.iosenv-row dt{color:var(--dsw-alias-label-secondary)}.iosenv-row dd{margin:0;overflow-wrap:anywhere}.iosenv-mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:12px}
|
|
80
|
+
.iosenv-actions{display:flex;flex-wrap:wrap;gap:8px}.iosenv-button{font:inherit;font-size:13px;line-height:20px;min-height:32px;border:.5px solid var(--dsw-alias-border-l2);border-radius:7px;padding:5px 12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);cursor:pointer}
|
|
81
|
+
.iosenv-button:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}.iosenv-button:disabled{cursor:not-allowed;opacity:.45}.iosenv-button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
82
|
+
.iosenv-primary{color:var(--dsw-alias-brand-primary);font-weight:500}.iosenv-error{border-left:3px solid var(--dsw-alias-state-error-primary);padding:8px 12px;background:var(--dsw-alias-bg-layer-1);overflow-wrap:anywhere}
|
|
83
|
+
.iosenv-confirm{border:.5px solid var(--dsw-alias-border-l2);border-radius:8px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}.iosenv-progress{width:100%;height:6px;accent-color:var(--dsw-alias-brand-primary)}
|
|
84
|
+
.iosenv-boundary{padding-top:8px;border-top:.5px solid var(--dsw-alias-border-l2)}
|
|
85
|
+
@media(max-width:520px){.iosenv-row{grid-template-columns:1fr;gap:3px}}
|
|
86
|
+
`;
|
|
87
|
+
const messageOf = (error, t) => error?.code === 'ios-environment/owner-required'
|
|
88
|
+
? t('ownerRequired')
|
|
89
|
+
: typeof error?.message === 'string' ? error.message.slice(0, 2048) : t('failure');
|
|
90
|
+
|
|
91
|
+
async function apply(ctx) {
|
|
92
|
+
const t = ctx.locale.bind(NS);
|
|
93
|
+
ctx.effect(() => ctx.locale.register(NS, dictionaries), 'iosEnvironment dictionaries');
|
|
94
|
+
// Await concrete namespace installation before either slot is registered.
|
|
95
|
+
await ctx.remote.$mount(remoteContribution);
|
|
96
|
+
// $mount creates the traced namespace Service dynamically. Read it through
|
|
97
|
+
// the public optional-service accessor; it cannot be a static inject before mount.
|
|
98
|
+
const environment = ctx.get('remote.iosEnvironment');
|
|
99
|
+
if (!environment) throw new Error('iosEnvironment Remote namespace did not mount.');
|
|
100
|
+
const subscribeLocale = listener => ctx.locale.subscribe(listener);
|
|
101
|
+
const localeSnapshot = () => ctx.locale.getSnapshot();
|
|
102
|
+
function EnvironmentPanel(props) {
|
|
103
|
+
React.useSyncExternalStore(subscribeLocale, localeSnapshot, localeSnapshot);
|
|
104
|
+
const [snapshot, setSnapshot] = React.useState(null);
|
|
105
|
+
const [error, setError] = React.useState(null);
|
|
106
|
+
const [working, setWorking] = React.useState(false);
|
|
107
|
+
const [confirmCleanup, setConfirmCleanup] = React.useState(false);
|
|
108
|
+
const [revision, setRevision] = React.useState(0);
|
|
109
|
+
const live = React.useRef(null);
|
|
110
|
+
const confirmedButton = React.useRef(null);
|
|
111
|
+
const dismissed = React.useRef(false);
|
|
112
|
+
const panelId = React.useId();
|
|
113
|
+
React.useEffect(() => {
|
|
114
|
+
const control = new AbortController();
|
|
115
|
+
live.current = control;
|
|
116
|
+
let stream;
|
|
117
|
+
let active = true;
|
|
118
|
+
(async () => {
|
|
119
|
+
try {
|
|
120
|
+
stream = environment.follow(control.signal);
|
|
121
|
+
for await (const value of stream) {
|
|
122
|
+
if (!active) return;
|
|
123
|
+
setSnapshot(snapshotSchema.parse(value));
|
|
124
|
+
setError(null);
|
|
125
|
+
}
|
|
126
|
+
if (active && !control.signal.aborted) setError({ message: t('followFailure') });
|
|
127
|
+
} catch (failure) {
|
|
128
|
+
if (active && !control.signal.aborted) setError(failure);
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
131
|
+
return () => {
|
|
132
|
+
active = false;
|
|
133
|
+
control.abort();
|
|
134
|
+
stream?.dispose();
|
|
135
|
+
if (live.current === control) live.current = null;
|
|
136
|
+
};
|
|
137
|
+
}, [revision]);
|
|
138
|
+
React.useEffect(() => { if (confirmCleanup) confirmedButton.current?.focus(); }, [confirmCleanup]);
|
|
139
|
+
// Activation guidance exists to get the environment ready, so it closes
|
|
140
|
+
// itself once preparation settles in a state that needs no action.
|
|
141
|
+
const settledReady = snapshot !== null && snapshot.phase === 'ready' && snapshot.busy !== true;
|
|
142
|
+
React.useEffect(() => {
|
|
143
|
+
if (!props.activation || !settledReady || dismissed.current) return;
|
|
144
|
+
dismissed.current = true;
|
|
145
|
+
if (typeof props.onDismiss === 'function') props.onDismiss();
|
|
146
|
+
}, [props.activation, settledReady, props.onDismiss]);
|
|
147
|
+
async function run(action) {
|
|
148
|
+
const control = live.current;
|
|
149
|
+
if (!control || control.signal.aborted || (working && !(action === 'cancel' && working === 'prepare'))) return;
|
|
150
|
+
setWorking(action);
|
|
151
|
+
setError(null);
|
|
152
|
+
try {
|
|
153
|
+
const result = await environment[action](control.signal);
|
|
154
|
+
if (control.signal.aborted) return;
|
|
155
|
+
if (!result.ok) { setError(result.error); return; }
|
|
156
|
+
setSnapshot(snapshotSchema.parse(result.value));
|
|
157
|
+
} catch (failure) {
|
|
158
|
+
if (!control.signal.aborted) setError(failure);
|
|
159
|
+
} finally {
|
|
160
|
+
if (!control.signal.aborted) setWorking(false);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
function button(label, action, disabled, extra = {}) {
|
|
164
|
+
return h('button', { type: 'button', className: 'iosenv-button', disabled: Boolean(disabled), onClick: action, ...extra }, label);
|
|
165
|
+
}
|
|
166
|
+
function row(label, value, mono = false) {
|
|
167
|
+
return h('div', { className: 'iosenv-row', key: label }, h('dt', null, label), h('dd', { className: mono ? 'iosenv-mono' : undefined }, value || t('missing')));
|
|
168
|
+
}
|
|
169
|
+
const busy = snapshot?.busy === true;
|
|
170
|
+
// Step and byte counts describe an in-flight preparation only; once it
|
|
171
|
+
// settles they must not read like a stalled download.
|
|
172
|
+
const progress = busy ? snapshot?.progress : null;
|
|
173
|
+
const step = busy ? snapshot?.step : null;
|
|
174
|
+
const total = progress?.totalBytes;
|
|
175
|
+
const progressText = progress ? t(total === undefined ? 'bytesUnknown' : 'bytes', { completed: progress.completedBytes, total }) : null;
|
|
176
|
+
const phase = snapshot?.phase;
|
|
177
|
+
const rows = snapshot ? [
|
|
178
|
+
row(t('source'), snapshot.python ? t(snapshot.python.source === 'host' ? 'sourceHost' : 'sourcePrivate') : null),
|
|
179
|
+
row(t('python'), snapshot.python?.version), row(t('path'), snapshot.python?.path, true),
|
|
180
|
+
...(snapshot.python?.abi ? [row(t('abi'), snapshot.python.abi, true)] : []),
|
|
181
|
+
...(snapshot.runtimeDir ? [row(t('runtimeDir'), snapshot.runtimeDir, true)] : []),
|
|
182
|
+
row(t('dependency'), snapshot.pymobiledevice3),
|
|
183
|
+
] : [];
|
|
184
|
+
return h('section', { className: 'iosenv-root', 'aria-labelledby': `${panelId}-title` },
|
|
185
|
+
h('style', null, css),
|
|
186
|
+
h('h3', { id: `${panelId}-title` }, t(props.activation ? 'activationTitle' : 'title')),
|
|
187
|
+
h('p', { className: 'iosenv-muted' }, t('intro')),
|
|
188
|
+
h('div', { className: 'iosenv-state', role: 'status', 'aria-live': 'polite' },
|
|
189
|
+
h('div', { className: 'iosenv-statehead' }, h('span', null, t('phase')), h('strong', { className: 'iosenv-phase' }, snapshot ? t(`phase.${phase}`) : t('loading'))),
|
|
190
|
+
step ? h('p', { className: 'iosenv-muted' }, `${t('step')}: ${step}`) : null,
|
|
191
|
+
progressText ? h('p', null, progressText) : null,
|
|
192
|
+
progress ? h('progress', { className: 'iosenv-progress', 'aria-label': t('progress'), ...(total > 0 ? { max: total, value: Math.min(progress.completedBytes, total) } : {}) }) : null,
|
|
193
|
+
phase === 'ready' ? h('p', { className: 'iosenv-muted' }, t('readyHint')) : null),
|
|
194
|
+
snapshot ? h('dl', { className: 'iosenv-rows' }, ...rows) : null,
|
|
195
|
+
snapshot?.error ? h('div', { className: 'iosenv-error', role: 'alert' }, h('p', null, snapshot.error.message), h('p', { className: 'iosenv-muted' }, `${t('errorCode')}: ${snapshot.error.code}`)) : null,
|
|
196
|
+
error ? h('div', { className: 'iosenv-error', role: 'alert' }, messageOf(error, t)) : null,
|
|
197
|
+
h('div', { className: 'iosenv-actions' },
|
|
198
|
+
button(t(phase === 'failed' || phase === 'cancelled' ? 'retry' : 'prepare'), () => run('prepare'), working || busy || !snapshot, { className: 'iosenv-button iosenv-primary' }),
|
|
199
|
+
button(t('cancel'), () => run('cancel'), !busy || (working && working !== 'prepare')),
|
|
200
|
+
button(t('cleanup'), () => setConfirmCleanup(true), working || busy || !snapshot),
|
|
201
|
+
button(t('refresh'), () => { setWorking(false); setRevision(value => value + 1); }, working)),
|
|
202
|
+
working ? h('p', { className: 'iosenv-muted', role: 'status' }, t('working')) : null,
|
|
203
|
+
confirmCleanup ? h('div', { className: 'iosenv-confirm', role: 'alertdialog', 'aria-labelledby': `${panelId}-confirm`, 'aria-describedby': `${panelId}-cleanup`, onKeyDown: event => { if (event.key === 'Escape') { event.preventDefault(); setConfirmCleanup(false); } } },
|
|
204
|
+
h('strong', { id: `${panelId}-confirm` }, t('cleanupTitle')),
|
|
205
|
+
h('p', { id: `${panelId}-cleanup`, className: 'iosenv-muted' }, t('cleanupBody')),
|
|
206
|
+
h('div', { className: 'iosenv-actions' },
|
|
207
|
+
button(t('back'), () => setConfirmCleanup(false), false, { ref: confirmedButton }),
|
|
208
|
+
button(t('cleanupConfirm'), () => { setConfirmCleanup(false); run('cleanup'); }, working || busy))) : null,
|
|
209
|
+
h('p', { className: 'iosenv-muted iosenv-boundary' }, t('boundary')),
|
|
210
|
+
props.activation ? h('div', { className: 'iosenv-actions' },
|
|
211
|
+
button(t('details'), props.onOpenDetails, false), button(t('dismiss'), props.onDismiss, false)) : null);
|
|
212
|
+
}
|
|
213
|
+
ctx.effect(() => ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
|
214
|
+
name: 'settings.plugins.tab', id: 'ios-environment', order: 80,
|
|
215
|
+
label: () => t('tab'),
|
|
216
|
+
}, EnvironmentPanel)), 'iosEnvironment settings tab');
|
|
217
|
+
ctx.effect(() => ctx.slots.inject('plugins.bundle.activation', () => ctx.slots.register({
|
|
218
|
+
name: 'plugins.bundle.activation', key: PACKAGE,
|
|
219
|
+
}, props => h(EnvironmentPanel, { ...props, activation: true }))), 'iosEnvironment activation panel');
|
|
220
|
+
}
|
|
221
|
+
return { inject: ['slots', 'locale', 'remote'], apply };
|
|
222
|
+
},
|
|
223
|
+
});
|
package/lib/core/dom.mjs
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// This function is serialized into the selected WebView. No application/framework knowledge.
|
|
2
|
+
export function domAction(args,env=globalThis){
|
|
3
|
+
const selector=args.selector;
|
|
4
|
+
const nodes=selector?env.document.querySelectorAll(selector):[env.document.scrollingElement];
|
|
5
|
+
if(nodes.length!==1||!nodes[0])throw new Error('selector must match exactly one element');
|
|
6
|
+
const e=nodes[0];
|
|
7
|
+
if(args.action==='scroll'){
|
|
8
|
+
if(!Number.isFinite(args.y)||args.y<0)throw new Error('y must be nonnegative');
|
|
9
|
+
const before=e.scrollTop;e.scrollTop=args.y;
|
|
10
|
+
return {kind:'programmatic-scroll',before,actual:e.scrollTop,eventDispatched:true,effectVerified:false,nativeGestureVerified:false};
|
|
11
|
+
}
|
|
12
|
+
for(let a=e;a;a=a.parentElement){const s=env.getComputedStyle(a);if(a.hidden||a.inert||a.disabled||a.getAttribute?.('aria-disabled')==='true'||s.display==='none'||s.visibility==='hidden'||s.visibility==='collapse'||Number(s.opacity)===0||s.pointerEvents==='none')throw new Error('element/ancestor is hidden, inert or disabled');}
|
|
13
|
+
const r=e.getBoundingClientRect(),l=Math.max(0,r.left),t=Math.max(0,r.top),rr=Math.min(env.innerWidth,r.right),b=Math.min(env.innerHeight,r.bottom);
|
|
14
|
+
if(r.width<=0||r.height<=0||rr<=l||b<=t)throw new Error('element is outside the WebView viewport');
|
|
15
|
+
const hit=env.document.elementFromPoint((l+rr)/2,(t+b)/2);if(!hit||(hit!==e&&!e.contains(hit)))throw new Error('element is covered by a DOM overlay');
|
|
16
|
+
if(args.action==='click')e.click();
|
|
17
|
+
else if(args.action==='input'){
|
|
18
|
+
const tag=e.tagName?.toLowerCase(),type=String(e.type||'text').toLowerCase();
|
|
19
|
+
if(!['input','textarea'].includes(tag)||['password','file','hidden','submit','button','checkbox','radio'].includes(type))throw new Error('unsupported or sensitive input type');
|
|
20
|
+
if(e.readOnly)throw new Error('input is read-only');
|
|
21
|
+
const proto=tag==='input'?env.HTMLInputElement.prototype:env.HTMLTextAreaElement.prototype;
|
|
22
|
+
const setter=Object.getOwnPropertyDescriptor(proto,'value')?.set;if(!setter)throw new Error('standard input value setter unavailable');
|
|
23
|
+
setter.call(e,args.value);e.dispatchEvent(new env.Event('input',{bubbles:true}));e.dispatchEvent(new env.Event('change',{bubbles:true}));
|
|
24
|
+
}else throw new Error('unsupported DOM action');
|
|
25
|
+
return {kind:'dom-'+args.action,eventDispatched:true,effectVerified:false,nativeTouchVerified:false};
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { mkdir, open, readFile, unlink } from 'node:fs/promises';
|
|
2
|
+
import { createHash,randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { IosError } from './protocol.mjs';
|
|
5
|
+
const key=s=>createHash('sha256').update(s).digest('hex');
|
|
6
|
+
export class LeaseStore {
|
|
7
|
+
constructor(dir,maxSeconds=120){this.dir=dir;this.maxSeconds=maxSeconds;}
|
|
8
|
+
file(device){return path.join(this.dir,key(device)+'.lease.json');}
|
|
9
|
+
async acquire(device,owner,seconds=60){
|
|
10
|
+
if(!Number.isInteger(seconds)||seconds<5||seconds>this.maxSeconds)throw new IosError('invalid_lease_duration','Lease seconds must be 5..'+this.maxSeconds);
|
|
11
|
+
await mkdir(this.dir,{recursive:true});const record={deviceHash:key(device),owner,token:randomUUID(),pid:process.pid,expiresAt:Date.now()+seconds*1000};
|
|
12
|
+
let h;try{h=await open(this.file(device),'wx',0o600);}catch(e){if(e.code==='EEXIST')throw new IosError('device_busy','Device lease exists. Do not steal expired/stale leases; verify and release its owner first.');throw e;}
|
|
13
|
+
try{await h.writeFile(JSON.stringify(record));}finally{await h.close();}return record;
|
|
14
|
+
}
|
|
15
|
+
async read(device){try{return JSON.parse(await readFile(this.file(device),'utf8'));}catch(e){if(e.code==='ENOENT')throw new IosError('lease_required','Acquire this device first.');throw e;}}
|
|
16
|
+
async assert(device,owner,token){const r=await this.read(device);if(r.owner!==owner||r.token!==token)throw new IosError('lease_not_owned','Lease belongs to another session.');if(r.expiresAt<=Date.now())throw new IosError('lease_expired','Lease expired; release your own token then acquire again.');return r;}
|
|
17
|
+
async release(device,owner,token){
|
|
18
|
+
const busy=this.file(device)+'.busy';let h;
|
|
19
|
+
try{h=await open(busy,'wx',0o600);}catch(e){if(e.code==='EEXIST')throw new IosError('device_busy','Cannot release a lease while its command is still running.');throw e;}
|
|
20
|
+
try{const r=await this.read(device);if(r.owner!==owner||r.token!==token)throw new IosError('lease_not_owned','Cannot release another session lease.');await unlink(this.file(device));return {released:true};}
|
|
21
|
+
finally{await h.close();await unlink(busy).catch(()=>{});}
|
|
22
|
+
}
|
|
23
|
+
async withCommand(device,owner,token,callback){
|
|
24
|
+
await this.assert(device,owner,token);const file=this.file(device)+'.busy';let h;
|
|
25
|
+
try{h=await open(file,'wx',0o600);}catch(e){if(e.code==='EEXIST')throw new IosError('device_busy','A command for this device is still in progress; no parallel input/read attachments.');throw e;}
|
|
26
|
+
let retain=false;
|
|
27
|
+
try{await h.writeFile(JSON.stringify({owner,pid:process.pid,startedAt:new Date().toISOString()}));await this.assert(device,owner,token);return await callback();}
|
|
28
|
+
catch(e){retain=e.details?.retainDeviceLock===true;throw e;}
|
|
29
|
+
finally{await h.close();if(!retain)await unlink(file).catch(()=>{});}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export class IosError extends Error {
|
|
3
|
+
constructor(code,message,details={}) { super(message);this.code=code;this.details=details; }
|
|
4
|
+
}
|
|
5
|
+
export function normalizeError(error,extra={}){
|
|
6
|
+
const code=typeof error?.code==='string'?error.code:(error?.name==='AbortError'?'cancelled':'operation_failed');
|
|
7
|
+
const message=error instanceof Error?error.message:(typeof error==='string'?error:'Operation failed or was cancelled.');
|
|
8
|
+
const details=error?.details&&Object.getPrototypeOf(error.details)===Object.prototype?error.details:{};
|
|
9
|
+
return new IosError(code,message,{...details,...extra});
|
|
10
|
+
}
|
|
11
|
+
export function assertSupported(platform,appType) {
|
|
12
|
+
if(platform!=='ios')throw new IosError('unsupported_platform','本插件拒绝此次非 iOS 调用:ios_* 设备工具仅接受 iOS WebView / 混合应用。本错误不评估 DSH 会话中其它工具的能力。');
|
|
13
|
+
if(!['webview','hybrid'].includes(appType))throw new IosError('unsupported_app_type','本插件拒绝此应用类型:仅接受明确确认的 iOS WebView / 混合应用;不能为通过校验而改标原生、Flutter、React Native 或未知应用。本错误不评估其它工具。');
|
|
14
|
+
}
|
|
15
|
+
export const CAPABILITIES=Object.freeze({source:'dsh-ios-webview-app-control',scope:'plugin-only',inventory:'not-session-inventory',platforms:['ios'],appTypes:['webview','hybrid'],webDom:true,jsEval:'disabled-by-default',webviewScreenshot:true,nativeElementTree:false,nativeTap:false,nativeSwipe:false,nativeScreenshot:false,nativeApps:false,flutterApps:false,android:false});
|
|
16
|
+
export function endpointUrl(value='http://127.0.0.1:9333') {
|
|
17
|
+
const u=new URL(value);
|
|
18
|
+
if(u.protocol!=='http:'||!['127.0.0.1','localhost'].includes(u.hostname)||u.username||u.password||u.search||u.hash||u.pathname!=='/')throw new IosError('unsafe_endpoint','Only loopback HTTP bridge origins without paths or credentials are supported.');
|
|
19
|
+
u.hostname='127.0.0.1';return u.origin;
|
|
20
|
+
}
|
|
21
|
+
export function boundedSignal(signal,ms) {return signal?AbortSignal.any([signal,AbortSignal.timeout(ms)]):AbortSignal.timeout(ms);}
|
|
22
|
+
export async function fetchJson(endpoint,pathname,{signal,timeoutMs=15000}={}) {
|
|
23
|
+
if(!['/json','/json/version'].includes(pathname))throw new IosError('invalid_bridge_path','Unsupported bridge discovery path.');
|
|
24
|
+
const r=await fetch(endpoint+pathname,{signal:boundedSignal(signal,timeoutMs),redirect:'error'});
|
|
25
|
+
if(!r.ok)throw new IosError('bridge_http_error','Bridge returned HTTP '+r.status);
|
|
26
|
+
const reader=r.body.getReader();const chunks=[];let size=0;
|
|
27
|
+
try{for(;;){const item=await reader.read();if(item.done)break;size+=item.value.byteLength;if(size>2*1024*1024){await reader.cancel();throw new IosError('result_too_large','Bridge discovery exceeded 2MiB.');}chunks.push(item.value);}}finally{reader.releaseLock();}
|
|
28
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
29
|
+
}
|
|
30
|
+
export function assertLosslessJson(value,seen=new Set()){
|
|
31
|
+
if(value===null||typeof value==='string'||typeof value==='boolean')return;
|
|
32
|
+
if(typeof value==='number'&&Number.isFinite(value)&&!Object.is(value,-0))return;
|
|
33
|
+
if(typeof value!=='object'||seen.has(value)||(!Array.isArray(value)&&Object.getPrototypeOf(value)!==Object.prototype))throw new IosError('non_json_result','Result must contain only lossless JSON values.');
|
|
34
|
+
seen.add(value);
|
|
35
|
+
if(Array.isArray(value)){for(let i=0;i<value.length;i++){if(!Object.hasOwn(value,i))throw new IosError('non_json_result','Sparse arrays are not lossless JSON.');assertLosslessJson(value[i],seen);}}
|
|
36
|
+
else for(const key of Reflect.ownKeys(value)){if(typeof key!=='string')throw new IosError('non_json_result','Symbol keys are not JSON.');const d=Object.getOwnPropertyDescriptor(value,key);if(!Object.hasOwn(d,'value'))throw new IosError('non_json_result','Accessor results are not JSON.');assertLosslessJson(d.value,seen);}
|
|
37
|
+
seen.delete(value);
|
|
38
|
+
}
|
|
39
|
+
export async function targets(endpoint,options){const a=await fetchJson(endpoint,'/json',options);if(!Array.isArray(a))throw new IosError('invalid_bridge','Expected a target array.');return a;}
|
|
40
|
+
export function exactPage(list,id,endpoint){
|
|
41
|
+
const matches=list.filter(t=>t.id===id&&t.type==='page');
|
|
42
|
+
if(matches.length!==1)throw new IosError('target_not_found','Exact page target is absent; list targets again. Node/JSContext is not a DOM page.');
|
|
43
|
+
const t=matches[0],ws=new URL(t.webSocketDebuggerUrl),base=new URL(endpoint);
|
|
44
|
+
if(ws.protocol!=='ws:'||!['127.0.0.1','localhost'].includes(ws.hostname)||ws.port!==base.port||ws.username||ws.password)throw new IosError('unsafe_websocket','Target socket does not belong to the selected loopback bridge.');
|
|
45
|
+
ws.hostname=base.hostname;return {...t,webSocketDebuggerUrl:ws.href};
|
|
46
|
+
}
|
|
47
|
+
export function remoteValue(p){
|
|
48
|
+
if(p.wasThrown||p.exceptionDetails)throw new IosError('javascript_exception',p.result?.description||p.exceptionDetails?.text||'Page JavaScript threw.');
|
|
49
|
+
const r=p.result;
|
|
50
|
+
if(r&&Object.hasOwn(r,'value'))return r.value;
|
|
51
|
+
if(r?.type==='undefined')return {type:'undefined'};
|
|
52
|
+
return {type:r?.type||'unknown',description:r?.description||'',unserializableValue:r?.unserializableValue||''};
|
|
53
|
+
}
|
|
54
|
+
export function decodeImage(data){
|
|
55
|
+
if(typeof data!=='string'||!data.length||data.length>40*1024*1024)throw new IosError('invalid_image','Screenshot data is empty or exceeds the 30MiB decoded bound.');
|
|
56
|
+
const bytes=Buffer.from(data,'base64');let format,mime,ext;
|
|
57
|
+
if(bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]))){format='png';mime='image/png';ext='.png';}
|
|
58
|
+
else if(bytes[0]===255&&bytes[1]===216&&bytes[2]===255){format='jpeg';mime='image/jpeg';ext='.jpg';}
|
|
59
|
+
else if(bytes.toString('ascii',0,4)==='RIFF'&&bytes.toString('ascii',8,12)==='WEBP'){format='webp';mime='image/webp';ext='.webp';}
|
|
60
|
+
else throw new IosError('invalid_image','Screenshot bytes are not PNG/JPEG/WebP.');
|
|
61
|
+
return {bytes,format,mime,ext,sha256:createHash('sha256').update(bytes).digest('hex')};
|
|
62
|
+
}
|
|
63
|
+
export class Cdp {
|
|
64
|
+
constructor(ws,signal,timeoutMs){
|
|
65
|
+
this.ws=ws;this.signal=signal;this.timeoutMs=timeoutMs;this.pending=new Map();this.seq=0;this.closed=false;this.transportClosed=false;this.closeWaiters=[];this.closePromise=null;
|
|
66
|
+
ws.addEventListener('message',e=>{let m;try{m=JSON.parse(String(e.data));}catch{return;}const p=this.pending.get(m.id);if(!p)return;clearTimeout(p.timer);this.pending.delete(m.id);if(m.error)p.reject(new IosError('cdp_error',m.error.message||'CDP failed.'));else p.resolve(m.result||{});});
|
|
67
|
+
ws.addEventListener('close',()=>{this.transportClosed=true;this.signal?.removeEventListener('abort',this.onAbort);this.rejectAll(new IosError('target_disconnected','Target disconnected; re-list before retry.'));for(const done of this.closeWaiters.splice(0))done();});
|
|
68
|
+
ws.addEventListener('error',()=>this.rejectAll(new IosError('websocket_error','Bridge socket error.')));
|
|
69
|
+
this.onAbort=()=>{this.rejectAll(new IosError('cancelled','Operation cancelled.'));void this.close().catch(()=>{});};
|
|
70
|
+
signal?.addEventListener('abort',this.onAbort,{once:true});
|
|
71
|
+
}
|
|
72
|
+
static async connect(url,{signal,timeoutMs=15000,factory=u=>new WebSocket(u)}={}){
|
|
73
|
+
signal?.throwIfAborted();const ws=factory(url),c=new Cdp(ws,signal,timeoutMs);
|
|
74
|
+
try{await new Promise((resolve,reject)=>{
|
|
75
|
+
const cleanup=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);};
|
|
76
|
+
const abort=()=>{cleanup();reject(new IosError('cancelled','Connection cancelled.'));};
|
|
77
|
+
const timer=setTimeout(()=>{cleanup();reject(new IosError('connect_timeout','CDP connect timeout.'));},timeoutMs);
|
|
78
|
+
ws.addEventListener('open',()=>{cleanup();resolve();},{once:true});
|
|
79
|
+
ws.addEventListener('error',()=>{cleanup();reject(new IosError('websocket_error','Cannot connect CDP.'));},{once:true});
|
|
80
|
+
ws.addEventListener('close',()=>{cleanup();reject(new IosError('target_disconnected','Target closed while connecting.'));},{once:true});
|
|
81
|
+
signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort();
|
|
82
|
+
});return c;}catch(e){await c.close();throw e;}
|
|
83
|
+
}
|
|
84
|
+
rejectAll(e){for(const p of this.pending.values()){clearTimeout(p.timer);p.reject(e);}this.pending.clear();}
|
|
85
|
+
send(method,params={}){
|
|
86
|
+
this.signal?.throwIfAborted();if(this.closed||this.transportClosed)return Promise.reject(new IosError('client_closed','CDP client is closed.'));
|
|
87
|
+
return new Promise((resolve,reject)=>{const id=++this.seq;const timer=setTimeout(()=>{this.pending.delete(id);reject(new IosError('command_timeout','Timed out: '+method));},this.timeoutMs);this.pending.set(id,{resolve,reject,timer});try{this.ws.send(JSON.stringify({id,method,params}));}catch(e){clearTimeout(timer);this.pending.delete(id);reject(e);}});
|
|
88
|
+
}
|
|
89
|
+
async evaluate(expression){return remoteValue(await this.send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true,userGesture:true}));}
|
|
90
|
+
async close(){
|
|
91
|
+
if(this.closePromise)return this.closePromise;
|
|
92
|
+
this.closed=true;this.signal?.removeEventListener('abort',this.onAbort);this.rejectAll(new IosError('client_closed','CDP client closed.'));
|
|
93
|
+
if(this.transportClosed)return;
|
|
94
|
+
this.closePromise=new Promise((resolve,reject)=>{
|
|
95
|
+
const timer=setTimeout(()=>reject(new IosError('socket_close_unknown','Socket close was not confirmed; keep device lock until owner verifies transport shutdown.',{retainDeviceLock:true})),Math.min(this.timeoutMs,3000));
|
|
96
|
+
const done=()=>{clearTimeout(timer);resolve();};this.closeWaiters.push(done);
|
|
97
|
+
try{this.ws.close();}catch(e){clearTimeout(timer);reject(new IosError('socket_close_unknown','Cannot confirm socket shutdown.',{retainDeviceLock:true}));}
|
|
98
|
+
});
|
|
99
|
+
return this.closePromise;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { statSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { IosError } from './protocol.mjs';
|
|
6
|
+
|
|
7
|
+
export function isolatedPythonEnv(env=process.env){
|
|
8
|
+
const clean={};
|
|
9
|
+
for(const [key,value]of Object.entries(env)){
|
|
10
|
+
if(/^(PYTHON|PIP|VIRTUAL_ENV|CONDA|__PYVENV_LAUNCHER__)/i.test(key))continue;
|
|
11
|
+
clean[key]=value;
|
|
12
|
+
}
|
|
13
|
+
// Device discovery must never inherit a user's module injection settings or
|
|
14
|
+
// write the host Python's bytecode/cache. Setup uses its own stricter env.
|
|
15
|
+
return{...clean,PYTHONUTF8:'1',PYTHONIOENCODING:'utf-8',PYTHONDONTWRITEBYTECODE:'1',PYTHONNOUSERSITE:'1',PYTHONPYCACHEPREFIX:path.join(os.homedir(),'.dsh-ios-webview-app-control','runtime','pycache')};
|
|
16
|
+
}
|
|
17
|
+
export function pythonPath(config={}, {env=process.env,platform=process.platform}={}){
|
|
18
|
+
let candidate=config.pythonPath||env.DSH_IOS_PYTHON;
|
|
19
|
+
if(!candidate&&env.DSH_PRIMARY_RUNTIME){
|
|
20
|
+
if(!path.isAbsolute(env.DSH_PRIMARY_RUNTIME))throw new IosError('python_missing','DSH_PRIMARY_RUNTIME must be an absolute runtime payload directory.');
|
|
21
|
+
candidate=path.join(env.DSH_PRIMARY_RUNTIME,'dependencies','python',platform==='win32'?'python.exe':'bin/python3');
|
|
22
|
+
}
|
|
23
|
+
// Harness home/cache layout is not a public API. Never guess ~/.dsh paths,
|
|
24
|
+
// search PATH, launch a Store alias or install a runtime as a side effect.
|
|
25
|
+
let isFile=false;
|
|
26
|
+
if(typeof candidate==='string'&&path.isAbsolute(candidate)){
|
|
27
|
+
try{isFile=statSync(candidate).isFile();}catch{}
|
|
28
|
+
}
|
|
29
|
+
if(!isFile)throw new IosError('python_missing','Configure pythonPath or DSH_IOS_PYTHON as an absolute Python interpreter with pymobiledevice3 installed, or set DSH_PRIMARY_RUNTIME to an existing payload. Use load_workspace_dependencies when available to obtain its Python path, then configure pythonPath explicitly. No automatic install or Windows Store alias.');
|
|
30
|
+
return candidate;
|
|
31
|
+
}
|
|
32
|
+
export async function runPython(executable,args,{signal,timeoutMs=15000}={}){
|
|
33
|
+
signal?.throwIfAborted();
|
|
34
|
+
return new Promise((resolve,reject)=>{
|
|
35
|
+
const child=spawn(executable,args,{shell:false,windowsHide:true,stdio:['ignore','pipe','pipe'],env:isolatedPythonEnv()});
|
|
36
|
+
let stdout='',stderr='',failure=null;let settled=false;
|
|
37
|
+
const stop=e=>{if(!failure)failure=e;child.kill();};
|
|
38
|
+
const abort=()=>stop(new IosError('cancelled','Python discovery cancelled.'));
|
|
39
|
+
const timer=setTimeout(()=>stop(new IosError('process_timeout','Python discovery timed out; terminated only its owned child.')),timeoutMs);
|
|
40
|
+
signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)abort();
|
|
41
|
+
const cleanup=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);};
|
|
42
|
+
child.stdout.on('data',b=>{stdout+=b.toString('utf8');if(Buffer.byteLength(stdout)>1024*1024)stop(new IosError('output_limit','Python discovery exceeded 1MiB.'));});
|
|
43
|
+
child.stderr.on('data',b=>{stderr+=b.toString('utf8');if(Buffer.byteLength(stderr)>1024*1024)stop(new IosError('output_limit','Python errors exceeded 1MiB.'));});
|
|
44
|
+
child.on('error',e=>{if(settled)return;settled=true;cleanup();reject(new IosError('process_error',e.message));});
|
|
45
|
+
child.on('close',code=>{if(settled)return;settled=true;cleanup();if(failure)reject(failure);else if(code!==0)reject(new IosError('python_error','Device discovery failed; check Apple service, USB, Trust and Web Inspector.',{exitCode:code}));else resolve(stdout);});
|
|
46
|
+
});
|
|
47
|
+
}
|