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,122 @@
|
|
|
1
|
+
import { mkdir,writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID,createHash } from 'node:crypto';
|
|
5
|
+
import { IosError,assertSupported,CAPABILITIES,endpointUrl,fetchJson,targets,exactPage,Cdp,decodeImage,assertLosslessJson,normalizeError } from './protocol.mjs';
|
|
6
|
+
import { LeaseStore } from './leases.mjs';
|
|
7
|
+
import { pythonPath,runPython } from './python.mjs';
|
|
8
|
+
import { domAction } from './dom.mjs';
|
|
9
|
+
export class IosControl {
|
|
10
|
+
constructor(config={},deps={}){
|
|
11
|
+
this.config={timeoutMs:15000,maxLeaseSeconds:120,allowEval:false,...config};
|
|
12
|
+
if(!Number.isSafeInteger(this.config.timeoutMs)||this.config.timeoutMs<1||this.config.timeoutMs>120000)throw new IosError('invalid_config','timeoutMs must be 1..120000.');
|
|
13
|
+
if(!Number.isSafeInteger(this.config.maxLeaseSeconds)||this.config.maxLeaseSeconds<5||this.config.maxLeaseSeconds>600)throw new IosError('invalid_config','maxLeaseSeconds must be 5..600.');
|
|
14
|
+
this.disposed=false;
|
|
15
|
+
this.stateDir=path.resolve(config.stateDir||path.join(os.homedir(),'.dsh-ios-webview-app-control'));
|
|
16
|
+
// Lease namespace is canonical per OS user, independent of evidence stateDir.
|
|
17
|
+
this.leases=deps.leases||new LeaseStore(path.join(os.homedir(),'.dsh-ios-webview-app-control','leases'),this.config.maxLeaseSeconds);
|
|
18
|
+
this.connections=new Map();this.leased=new Map();this.controllers=new Set();this.active=new Set();this.connectionBusy=new Set();
|
|
19
|
+
this.run=deps.runPython||runPython;this.environment=deps.environment;this.discoverTargets=deps.targets||targets;this.fetchJson=deps.fetchJson||fetchJson;this.connectCdp=deps.connectCdp||Cdp.connect.bind(Cdp);
|
|
20
|
+
}
|
|
21
|
+
connection(id,owner){const c=this.connections.get(id);if(!c||c.owner!==owner)throw new IosError('connection_not_owned','Unknown connection for this session.');assertSupported(c.platform,c.appType);return c;}
|
|
22
|
+
async execute(action,args={},exec={}){
|
|
23
|
+
if(action==='capabilities')return {ok:true,capabilities:CAPABILITIES,authorizationGranted:false,deviceAccessPerformed:false,unsupportedMessage:'本插件仅实现 iOS WebView / 混合应用控制。android:false、nativeTap:false 等只表示本插件未实现,不是整个 DSH 会话的工具清单,也不评估其它工具、设备连接或访问权限。'};
|
|
24
|
+
if(this.disposed)throw new IosError('service_disposed','Plugin has been disposed.');
|
|
25
|
+
if(!exec.owner||typeof exec.owner!=='string')throw new IosError('owner_required','An owning DSH session is required.');
|
|
26
|
+
const ac=new AbortController();this.controllers.add(ac);const signal=exec.signal?AbortSignal.any([exec.signal,ac.signal]):ac.signal;
|
|
27
|
+
const work=this.dispatch(action,args,{...exec,signal});this.active.add(work);
|
|
28
|
+
try{const result=await work;assertLosslessJson(result);return result;}catch(error){throw normalizeError(error);}finally{this.active.delete(work);this.controllers.delete(ac);}
|
|
29
|
+
}
|
|
30
|
+
async dispatch(action,args,{owner,signal}){
|
|
31
|
+
signal.throwIfAborted();
|
|
32
|
+
if(action==='devices'){
|
|
33
|
+
const interpreter=this.environment?await this.environment.requirePython():pythonPath(this.config);
|
|
34
|
+
const raw=await this.run(interpreter,['-I','-m','pymobiledevice3','--no-color','usbmux','list'],{signal,timeoutMs:this.config.timeoutMs});
|
|
35
|
+
const list=JSON.parse(raw);if(!Array.isArray(list))throw new IosError('invalid_device_list','Expected usbmux device list.');
|
|
36
|
+
return {ok:true,devices:list.map(d=>({udid:d.Identifier||d.UniqueDeviceID||null,name:d.DeviceName||null,version:d.ProductVersion||null,connectionType:d.ConnectionType||null}))};
|
|
37
|
+
}
|
|
38
|
+
if(action==='connect'){
|
|
39
|
+
assertSupported(args.platform,args.appType); // Hard refusal before HTTP, device I/O or lease creation.
|
|
40
|
+
if(typeof args.udid!=='string'||!args.udid.trim()||args.verifiedExistingUdid!==args.udid)throw new IosError('device_binding_unverified','An explicit human-confirmed verifiedExistingUdid equal to udid is required; do not infer from PID/title.');
|
|
41
|
+
if(!args.endpoint&&!this.config.endpoint)throw new IosError('endpoint_required','Configure or explicitly select an existing bridge endpoint.');
|
|
42
|
+
const endpoint=endpointUrl(args.endpoint||this.config.endpoint);
|
|
43
|
+
const version=await this.fetchJson(endpoint,'/json/version',{signal,timeoutMs:this.config.timeoutMs});
|
|
44
|
+
if(version['User-Agent']!=='pymobiledevice3')throw new IosError('wrong_bridge','Existing endpoint is not a pymobiledevice3 bridge.');
|
|
45
|
+
const list=await this.discoverTargets(endpoint,{signal,timeoutMs:this.config.timeoutMs});
|
|
46
|
+
signal.throwIfAborted();
|
|
47
|
+
const id=randomUUID();const c={id,owner,endpoint,udid:args.udid,platform:args.platform,appType:args.appType,binding:'operator-attested',connectedAt:new Date().toISOString()};
|
|
48
|
+
this.connections.set(id,c);
|
|
49
|
+
return {ok:true,connectionId:id,endpoint,deviceBinding:'operator-attested-not-automatically-proven',appTypeValidation:'operator-declared-not-automatically-proven',targetCount:list.length,capabilities:CAPABILITIES,buildIdentity:'unverified',warning:'Coordinate target ownership before attaching: this bridge can take over another debugger connection. No app type can be inferred solely from presence of a WebView.'};
|
|
50
|
+
}
|
|
51
|
+
const c=this.connection(args.connectionId,owner);
|
|
52
|
+
if(this.connectionBusy.has(c.id))throw new IosError('connection_busy','Another operation owns this connection; wait for it to settle.');
|
|
53
|
+
this.connectionBusy.add(c.id);
|
|
54
|
+
try{
|
|
55
|
+
if(action==='disconnect'){
|
|
56
|
+
const l=this.leased.get(c.id);if(l){await this.leases.release(c.udid,owner,l.token);this.leased.delete(c.id);}this.connections.delete(c.id);return {ok:true,disconnected:true,bridgeStopped:false};
|
|
57
|
+
}
|
|
58
|
+
if(action==='targets'){
|
|
59
|
+
const list=await this.discoverTargets(c.endpoint,{signal,timeoutMs:this.config.timeoutMs});
|
|
60
|
+
return {ok:true,connectionId:c.id,targets:list.map(t=>({id:t.id||null,title:t.title||null,type:t.type||null,url:t.url||null})),warning:'Exact id only. No target list ordering or numeric suffix proves foreground visibility.'};
|
|
61
|
+
}
|
|
62
|
+
if(action==='bind'){
|
|
63
|
+
assertSupported(args.platform,args.appType);
|
|
64
|
+
if(args.verifiedTargetId!==args.targetId||typeof args.targetId!=='string'||typeof args.appIdentity!=='string'||!args.appIdentity.trim()||args.appIdentity.length>256)throw new IosError('target_binding_unverified','Explicit operator confirmation of targetId, supported app type and appIdentity is required. Never infer app type from an inspectable page.');
|
|
65
|
+
const list=await this.discoverTargets(c.endpoint,{signal,timeoutMs:this.config.timeoutMs});const t=exactPage(list,args.targetId,c.endpoint);signal.throwIfAborted();
|
|
66
|
+
c.targetBinding={id:t.id,title:t.title||null,url:t.url||null,socket:t.webSocketDebuggerUrl,appIdentity:args.appIdentity.trim(),platform:args.platform,appType:args.appType};
|
|
67
|
+
return {ok:true,targetBinding:{...c.targetBinding,socket:null},validation:'operator-attested-not-automatically-proven',warning:'Only this exact target is eligible for this plugin. New or changed targets require a new supported-app confirmation. This plugin rejects native/Flutter/Android/unknown targets; other tools are not assessed.'};
|
|
68
|
+
}
|
|
69
|
+
if(action==='acquire'){
|
|
70
|
+
const lease=await this.leases.acquire(c.udid,owner,args.seconds??Math.min(60,this.config.maxLeaseSeconds));
|
|
71
|
+
if(signal.aborted){await this.leases.release(c.udid,owner,lease.token);signal.throwIfAborted();}
|
|
72
|
+
this.leased.set(c.id,lease);return {ok:true,leaseToken:lease.token,expiresAt:new Date(lease.expiresAt).toISOString(),warning:'Device-wide lease coordinates this plugin only; do not attach another DevTools/automation client concurrently.'};
|
|
73
|
+
}
|
|
74
|
+
if(action==='release'){
|
|
75
|
+
if(this.leased.get(c.id)?.token!==args.leaseToken)throw new IosError('lease_not_owned','Lease token was not issued to this connection.');
|
|
76
|
+
const result=await this.leases.release(c.udid,owner,args.leaseToken);this.leased.delete(c.id);return {ok:true,...result};
|
|
77
|
+
}
|
|
78
|
+
if(!['read','screenshot','eval','dom'].includes(action))throw new IosError('unsupported_action','This plugin does not implement the requested operation. Its WebView tools do not provide native/Flutter/Android control; other session tools are not assessed.');
|
|
79
|
+
if(!this.leased.has(c.id))throw new IosError('lease_required','Acquire a lease for this exact connection first.');
|
|
80
|
+
if(this.leased.get(c.id).token!==args.leaseToken)throw new IosError('lease_not_owned','Lease token was not issued to this connection.');
|
|
81
|
+
return await this.leases.withCommand(c.udid,owner,args.leaseToken,async()=>{
|
|
82
|
+
const list=await this.discoverTargets(c.endpoint,{signal,timeoutMs:this.config.timeoutMs});
|
|
83
|
+
const t=exactPage(list,args.targetId,c.endpoint);
|
|
84
|
+
const bound=c.targetBinding;
|
|
85
|
+
if(!bound||bound.id!==t.id)throw new IosError('target_not_bound','This exact target has not been explicitly confirmed as belonging to a supported iOS WebView/hybrid app. Bind it before reading or input.');
|
|
86
|
+
assertSupported(bound.platform,bound.appType);
|
|
87
|
+
if(bound.title!==(t.title||null)||bound.url!==(t.url||null)||bound.socket!==t.webSocketDebuggerUrl){c.targetBinding=null;throw new IosError('target_changed','Target identity changed; re-list and explicitly rebind.');}
|
|
88
|
+
if(action==='eval'&&!this.config.allowEval)throw new IosError('eval_disabled','Arbitrary JS is disabled by deployment configuration.');
|
|
89
|
+
if(action==='eval'&&(typeof args.expression!=='string'||args.expression.length>100000))throw new IosError('invalid_expression','Expression must be a string up to 100000 characters.');
|
|
90
|
+
const client=await this.connectCdp(t.webSocketDebuggerUrl,{signal,timeoutMs:this.config.timeoutMs});let mutationDispatched=false;
|
|
91
|
+
try{
|
|
92
|
+
await client.send('Runtime.enable');let data;
|
|
93
|
+
if(action==='read'){
|
|
94
|
+
const limit=args.limit??4000;if(!Number.isSafeInteger(limit)||limit<1||limit>20000)throw new IosError('invalid_limit','Text limit must be 1..20000.');
|
|
95
|
+
const selector=JSON.stringify(args.selector||'body');
|
|
96
|
+
data=await client.evaluate('(()=>{const es=document.querySelectorAll('+selector+');if(es.length!==1)throw new Error("selector must match exactly one element");return {text:(es[0].innerText||es[0].textContent||"").slice(0,'+limit+'),source:"untrusted-page-content"};})()');
|
|
97
|
+
}
|
|
98
|
+
if(action==='eval'){mutationDispatched=true;data=await client.evaluate(args.expression);}
|
|
99
|
+
if(action==='dom'){
|
|
100
|
+
if(!['click','scroll','input'].includes(args.action))throw new IosError('unsupported_dom_action','Only DOM click/scroll/standard text input are supported, not native input.');
|
|
101
|
+
if(args.action!=='scroll'&&(typeof args.selector!=='string'||!args.selector))throw new IosError('selector_required','An exact unique CSS selector is required.');
|
|
102
|
+
if(args.action==='input'&&(typeof args.value!=='string'||args.value.length>10000))throw new IosError('invalid_input','Text must be up to 10000 characters; passwords/payment/CAPTCHA are prohibited.');
|
|
103
|
+
mutationDispatched=true;data=await client.evaluate('('+domAction.toString()+')('+JSON.stringify({action:args.action,selector:args.selector,y:args.y,value:args.value})+')');
|
|
104
|
+
}
|
|
105
|
+
if(action==='screenshot'){
|
|
106
|
+
await client.send('Page.enable');const response=await client.send('Page.captureScreenshot',{format:'png'});const image=decodeImage(response.data);
|
|
107
|
+
const dir=path.join(this.stateDir,'evidence',createHash('sha256').update(owner).digest('hex').slice(0,16));await mkdir(dir,{recursive:true});
|
|
108
|
+
const file=path.join(dir,Date.now()+'-'+randomUUID()+image.ext);
|
|
109
|
+
const meta={capturedAt:new Date().toISOString(),target:{id:t.id,title:t.title||null,url:t.url||null},captureKind:'webview-render',nativeDeviceScreenshot:false,buildIdentity:'unverified',mime:image.mime,bytes:image.bytes.length,sha256:image.sha256};
|
|
110
|
+
signal.throwIfAborted();
|
|
111
|
+
await writeFile(file,image.bytes,{flag:'wx',mode:0o600});await writeFile(file+'.json',JSON.stringify(meta,null,2),{flag:'wx',mode:0o600});
|
|
112
|
+
data={file,metadataFile:file+'.json',...meta};
|
|
113
|
+
}
|
|
114
|
+
signal.throwIfAborted();assertLosslessJson(data);const encoded=JSON.stringify(data);if(encoded===undefined||encoded.length>250000)throw new IosError('result_too_large','Result is not JSON-safe or exceeds output bound.');
|
|
115
|
+
return {ok:true,target:{id:t.id,title:t.title||null},data,captureKind:action==='screenshot'?'webview-render':null,operationKind:action,buildIdentity:'unverified',effectVerified:false};
|
|
116
|
+
}catch(e){throw normalizeError(e,mutationDispatched?{operationOutcome:'unknown',advice:'Dispatched changes are not rolled back by errors/cancellation. Verify state before retry; never auto-replay submissions.'}:{});}
|
|
117
|
+
finally{try{await client.close();}catch(e){throw normalizeError(e,mutationDispatched?{operationOutcome:'unknown'}:{});}}
|
|
118
|
+
});
|
|
119
|
+
}finally{this.connectionBusy.delete(c.id);}
|
|
120
|
+
}
|
|
121
|
+
async dispose(){this.disposed=true;for(const c of this.controllers)c.abort();await Promise.allSettled([...this.active]);for(const [id,l] of this.leased){const c=this.connections.get(id);if(c)await this.leases.release(c.udid,c.owner,l.token).catch(()=>{});}this.leased.clear();this.connections.clear();}
|
|
122
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol';
|
|
2
|
+
import { environmentHostContribution, projectEnvironmentSnapshot } from '../typert-environment.mjs';
|
|
3
|
+
|
|
4
|
+
const ACTIONS = new Set(['status', 'prepare', 'cancel', 'cleanup']);
|
|
5
|
+
const MANAGER_METHODS = ['status', 'subscribe', 'prepare', 'cancel', 'cleanup'];
|
|
6
|
+
|
|
7
|
+
function failure(code, message) {
|
|
8
|
+
return new RemoteError(`ios-environment/${code}`, message, {});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function assertSignal(signal) {
|
|
12
|
+
if (signal !== undefined && (!(signal instanceof AbortSignal))) {
|
|
13
|
+
throw failure('invalid-signal', 'Environment operations require an AbortSignal supplied by the Host.');
|
|
14
|
+
}
|
|
15
|
+
signal?.throwIfAborted();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Shared, Host-only operation controller. The parent plugin owns manager.dispose().
|
|
20
|
+
* The agent tool must perform its DSH sandbox/one-shot-approval gate BEFORE execute.
|
|
21
|
+
* No caller-supplied authority, Python path, download URL or device id is accepted.
|
|
22
|
+
*/
|
|
23
|
+
export function createIosEnvironmentController(manager) {
|
|
24
|
+
for (const key of MANAGER_METHODS) {
|
|
25
|
+
if (typeof manager?.[key] !== 'function') throw new TypeError(`EnvironmentManager.${key} is required.`);
|
|
26
|
+
}
|
|
27
|
+
const lifetime = new AbortController();
|
|
28
|
+
const active = new Set();
|
|
29
|
+
const followers = new Set();
|
|
30
|
+
let disposed = false;
|
|
31
|
+
let disposal;
|
|
32
|
+
const check = signal => {
|
|
33
|
+
if (disposed) throw failure('disposed', 'The environment service has been unloaded.');
|
|
34
|
+
assertSignal(signal);
|
|
35
|
+
};
|
|
36
|
+
const snapshot = () => {
|
|
37
|
+
try { return projectEnvironmentSnapshot(manager.status()); }
|
|
38
|
+
catch { throw failure('invalid-status', 'The environment manager returned an invalid public status.'); }
|
|
39
|
+
};
|
|
40
|
+
async function execute(action, options = {}) {
|
|
41
|
+
if (!ACTIONS.has(action)) throw failure('invalid-action', 'Unknown environment action.');
|
|
42
|
+
if (options === null || typeof options !== 'object' || Array.isArray(options)
|
|
43
|
+
|| Reflect.ownKeys(options).some(key => key !== 'signal')) {
|
|
44
|
+
throw failure('invalid-options', 'Environment options accept only the Host execution signal.');
|
|
45
|
+
}
|
|
46
|
+
check(options.signal);
|
|
47
|
+
const signal = options.signal ? AbortSignal.any([lifetime.signal, options.signal]) : lifetime.signal;
|
|
48
|
+
if (action === 'status') return snapshot();
|
|
49
|
+
const work = (async () => {
|
|
50
|
+
signal.throwIfAborted();
|
|
51
|
+
try {
|
|
52
|
+
if (action === 'prepare') await manager.prepare({ signal });
|
|
53
|
+
else if (action === 'cancel') await manager.cancel();
|
|
54
|
+
else await manager.cleanup({ signal });
|
|
55
|
+
} catch (error) {
|
|
56
|
+
signal.throwIfAborted();
|
|
57
|
+
// EnvironmentManager publishes safe structured failures. Do not leak
|
|
58
|
+
// subprocess stderr, environment variables, or arbitrary Error details.
|
|
59
|
+
const state = snapshot();
|
|
60
|
+
throw failure(state.error?.code ?? 'operation-failed', state.error?.message ?? 'Environment operation failed. See its status and retry explicitly.');
|
|
61
|
+
}
|
|
62
|
+
signal.throwIfAborted();
|
|
63
|
+
return snapshot();
|
|
64
|
+
})();
|
|
65
|
+
active.add(work);
|
|
66
|
+
try { return await work; }
|
|
67
|
+
finally { active.delete(work); }
|
|
68
|
+
}
|
|
69
|
+
function follow(callerSignal) {
|
|
70
|
+
check(callerSignal);
|
|
71
|
+
let closed = false;
|
|
72
|
+
let pending;
|
|
73
|
+
let wake;
|
|
74
|
+
let off;
|
|
75
|
+
const signal = callerSignal ? AbortSignal.any([lifetime.signal, callerSignal]) : lifetime.signal;
|
|
76
|
+
const touch = () => {
|
|
77
|
+
if (closed) return;
|
|
78
|
+
// Coalesce updates: a slow UI retains one latest snapshot, not a log.
|
|
79
|
+
pending = true;
|
|
80
|
+
wake?.();
|
|
81
|
+
};
|
|
82
|
+
const close = () => {
|
|
83
|
+
if (closed) return;
|
|
84
|
+
closed = true;
|
|
85
|
+
off?.();
|
|
86
|
+
signal.removeEventListener('abort', close);
|
|
87
|
+
followers.delete(close);
|
|
88
|
+
wake?.();
|
|
89
|
+
};
|
|
90
|
+
followers.add(close);
|
|
91
|
+
try {
|
|
92
|
+
off = manager.subscribe(touch);
|
|
93
|
+
if (typeof off !== 'function') throw new TypeError('EnvironmentManager.subscribe must return an unsubscribe function.');
|
|
94
|
+
signal.addEventListener('abort', close, { once: true });
|
|
95
|
+
if (signal.aborted) close();
|
|
96
|
+
else touch(); // Subscribe before the opening baseline; status grants nothing.
|
|
97
|
+
} catch (error) {
|
|
98
|
+
close();
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
const iterator = {
|
|
102
|
+
[Symbol.asyncIterator]() { return this; },
|
|
103
|
+
async next() {
|
|
104
|
+
while (!closed && !pending) await new Promise(resolve => { wake = resolve; });
|
|
105
|
+
wake = undefined;
|
|
106
|
+
if (closed) return { done: true, value: undefined };
|
|
107
|
+
pending = false;
|
|
108
|
+
try { return { done: false, value: snapshot() }; }
|
|
109
|
+
catch (error) { close(); throw error; }
|
|
110
|
+
},
|
|
111
|
+
return() { close(); return Promise.resolve({ done: true, value: undefined }); },
|
|
112
|
+
};
|
|
113
|
+
return iterator;
|
|
114
|
+
}
|
|
115
|
+
function dispose() {
|
|
116
|
+
if (disposal) return disposal;
|
|
117
|
+
disposed = true;
|
|
118
|
+
lifetime.abort();
|
|
119
|
+
for (const close of [...followers]) close();
|
|
120
|
+
disposal = Promise.allSettled([...active]).then(() => undefined);
|
|
121
|
+
return disposal;
|
|
122
|
+
}
|
|
123
|
+
return Object.freeze({ execute, follow, dispose });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ownerInvocation(service, signal) {
|
|
127
|
+
const invocation = service.ctx.invocation;
|
|
128
|
+
const peer = invocation?.peer;
|
|
129
|
+
// Gateway-owned invocation identity is independent of status read access.
|
|
130
|
+
// A missing invocation, passive non-owner or disposed Peer never gains a write.
|
|
131
|
+
if (invocation?.service !== 'iosEnvironment'
|
|
132
|
+
|| invocation?.request?.namespace !== 'iosEnvironment'
|
|
133
|
+
|| peer?.principal?.kind !== 'owner'
|
|
134
|
+
|| !(peer.signal instanceof AbortSignal)
|
|
135
|
+
|| peer.signal.aborted) {
|
|
136
|
+
throw failure('owner-required', 'Only an authenticated owner UI may manage the local environment.');
|
|
137
|
+
}
|
|
138
|
+
assertSignal(signal);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Remote wrappers have no wire business parameters and all require owner Peer. */
|
|
142
|
+
export class IosEnvironmentService extends TypertRemoteService {
|
|
143
|
+
constructor(ctx, controller) {
|
|
144
|
+
super(ctx, 'iosEnvironment');
|
|
145
|
+
this.controller = controller;
|
|
146
|
+
}
|
|
147
|
+
status(signal) { ownerInvocation(this, signal); return this.controller.execute('status', { signal }); }
|
|
148
|
+
prepare(signal) { ownerInvocation(this, signal); return this.controller.execute('prepare', { signal }); }
|
|
149
|
+
cancel(signal) { ownerInvocation(this, signal); return this.controller.execute('cancel', { signal }); }
|
|
150
|
+
cleanup(signal) { ownerInvocation(this, signal); return this.controller.execute('cleanup', { signal }); }
|
|
151
|
+
follow(signal) { ownerInvocation(this, signal); return this.controller.follow(signal); }
|
|
152
|
+
/** Host only: intentionally absent from every Remote descriptor/marker. */
|
|
153
|
+
execute(action, options) {
|
|
154
|
+
if (this.ctx.invocation !== undefined) throw failure('host-only', 'Host environment execution is not a Remote endpoint.');
|
|
155
|
+
return this.controller.execute(action, options);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Register on the parent's owning Cordis context; never starts preparation.
|
|
161
|
+
* Parent creates/owns EnvironmentManager and calls this only when typert exists.
|
|
162
|
+
* @returns {{service: IosEnvironmentService, controller: object, dispose: Function}}
|
|
163
|
+
*/
|
|
164
|
+
export function registerIosEnvironment(ctx, { manager, controller = createIosEnvironmentController(manager) } = {}) {
|
|
165
|
+
const typert = ctx?.get?.('typert');
|
|
166
|
+
if (typeof typert?.register !== 'function') throw new TypeError('The typert registry is required.');
|
|
167
|
+
let service;
|
|
168
|
+
const dispose = ctx.effect(function* () {
|
|
169
|
+
// Reverse disposal removes strict endpoints before ending followers.
|
|
170
|
+
yield () => controller.dispose();
|
|
171
|
+
service = new IosEnvironmentService(ctx, controller);
|
|
172
|
+
yield typert.register(environmentHostContribution);
|
|
173
|
+
}, 'iosEnvironment strict service registrations');
|
|
174
|
+
return { service, controller, dispose };
|
|
175
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {IosError} from '../core/protocol.mjs';
|
|
2
|
+
|
|
3
|
+
/** Adapter for the DSH-owned process range; never shell-interpolates argv. */
|
|
4
|
+
export function createManagedRunner(subprocess){
|
|
5
|
+
return async function run(executable,args,{signal,timeoutMs=15000,env,cwd,onOutput}={}){
|
|
6
|
+
signal?.throwIfAborted();
|
|
7
|
+
const timeout=new AbortController();
|
|
8
|
+
const timer=setTimeout(()=>timeout.abort(new IosError('process_timeout','Environment subprocess exceeded its deadline.')),timeoutMs);
|
|
9
|
+
const combined=signal?AbortSignal.any([signal,timeout.signal]):timeout.signal;
|
|
10
|
+
let handle;
|
|
11
|
+
try{
|
|
12
|
+
handle=subprocess.spawn({
|
|
13
|
+
argv:[executable,...args],cwd,
|
|
14
|
+
env,signal:combined,graceMs:3000,
|
|
15
|
+
stdio:{stdin:'ignore',stdout:{maxBytes:1024*1024},stderr:{maxBytes:1024*1024}},
|
|
16
|
+
});
|
|
17
|
+
const outcome=await handle.done;
|
|
18
|
+
const stdout=handle.collected.stdout?.readFrom(0),stderr=handle.collected.stderr?.readFrom(0);
|
|
19
|
+
if(stdout?.lossy||stderr?.lossy)throw new IosError('output_limit','Environment subprocess output exceeded its bound.');
|
|
20
|
+
if(stdout?.text)onOutput?.('stdout',stdout.text);
|
|
21
|
+
if(stderr?.text)onOutput?.('stderr',stderr.text);
|
|
22
|
+
combined.throwIfAborted();
|
|
23
|
+
return{stdout:stdout?.text||'',stderr:stderr?.text||'',exitCode:outcome.exitCode??1};
|
|
24
|
+
}finally{
|
|
25
|
+
clearTimeout(timer);
|
|
26
|
+
if(handle){
|
|
27
|
+
// The provider controls its complete owned process range, including
|
|
28
|
+
// descendants that inherited pipes. Join termination on every path.
|
|
29
|
+
handle.terminate();
|
|
30
|
+
await handle.waitForExit();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|