dsh-mobile 0.3.14 → 0.3.16
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 +31 -0
- package/README.en.md +12 -9
- package/README.md +12 -9
- package/SECURITY.md +1 -1
- package/docs/DSH_0.1.5_LAN.md +27 -0
- package/lib/cli.js.map +1 -1
- package/lib/client.js +41 -8
- package/lib/client.js.map +1 -1
- package/lib/index.d.mts +13 -0
- package/lib/index.mjs +209 -27
- package/lib/index.mjs.map +1 -1
- package/lib/mobile-layout.js +193 -33
- package/lib/mobile-layout.js.map +1 -1
- package/package.json +14 -12
package/lib/cli.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.js","names":["execFile","execFile","execFile"],"sources":["../src/exec-file.ts","../src/private-file.ts","../src/managed-setup.ts","../src/extensions.ts","../src/cli.ts"],"sourcesContent":["import { execFile, type ExecFileOptions } from 'node:child_process'\n\n/** Capture both output streams even when a desktop host wraps execFile without Node's promisify metadata. */\nexport function execFileText(\n file: string,\n args: readonly string[],\n options: Omit<ExecFileOptions, 'encoding'> & { encoding?: 'utf8' } = {},\n): Promise<{ stdout: string; stderr: string }> {\n return new Promise((resolve, reject) => {\n execFile(file, [...args], { windowsHide: true, ...options, encoding: 'utf8' }, (error, stdout, stderr) => {\n if (error !== null) { reject(error); return }\n if (typeof stdout !== 'string' || typeof stderr !== 'string') {\n reject(new Error('subprocess returned invalid text output'))\n return\n }\n resolve({ stdout, stderr })\n })\n })\n}\n","import { chmod } from 'node:fs/promises'\nimport { execFileText as execFile } from './exec-file.js'\n\nlet userSidTask: Promise<string> | undefined\n\nasync function currentWindowsUserSid(): Promise<string> {\n userSidTask ??= execFile('whoami.exe', ['/user', '/fo', 'csv', '/nh'], {\n encoding: 'utf8',\n windowsHide: true,\n timeout: 10_000,\n }).then(({ stdout }) => {\n const match = /,\"(S-\\d(?:-\\d+)+)\"\\s*$/u.exec(stdout.trim())\n if (match?.[1] === undefined) throw new Error('unable to resolve the current Windows user SID')\n return match[1]\n }).catch((error: unknown) => {\n userSidTask = undefined\n throw error\n })\n return userSidTask\n}\n\n/** Restrict a sensitive regular file to the current user and Windows administrators. */\nexport async function restrictPrivateFile(file: string, mode = 0o600): Promise<void> {\n await chmod(file, mode)\n if (process.platform !== 'win32') return\n const userSid = await currentWindowsUserSid()\n await execFile('icacls.exe', [\n file,\n '/inheritance:r',\n '/grant:r',\n `*${userSid}:(F)`,\n '*S-1-5-18:(F)',\n '*S-1-5-32-544:(F)',\n '/remove:g',\n '*S-1-1-0',\n '*S-1-5-11',\n '*S-1-5-32-545',\n ], { encoding: 'utf8', windowsHide: true, timeout: 10_000 })\n}\n","import {\n X509Certificate,\n createPrivateKey,\n createPublicKey,\n} from 'node:crypto'\nimport { execFileText as execFile } from './exec-file.js'\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { networkInterfaces, type NetworkInterfaceInfo } from 'node:os'\nimport { basename, dirname, join } from 'node:path'\nimport { generate } from 'selfsigned'\nimport { restrictPrivateFile } from './private-file.js'\n\n/** One active private IPv4 address tied to a stable operating-system interface name. */\nexport interface LanNetwork {\n readonly name: string\n readonly address: string\n readonly cidr: string\n}\n\n/** Versioned setup that survives DHCP address changes on the selected interface. */\nexport interface ManagedSetup {\n readonly version: 2\n readonly networkInterface: string\n readonly listenPort: number\n readonly upstreamOrigin: string\n readonly tls: {\n readonly mode: 'managed'\n readonly caCertFile: string\n readonly caKeyFile: string\n readonly certFile: string\n readonly keyFile: string\n }\n}\n\ntype InterfaceTable = NodeJS.Dict<NetworkInterfaceInfo[]>\ntype RouteCommand = (file: string, args: readonly string[]) => Promise<string>\n\nconst VIRTUAL_INTERFACE_MARKERS = [\n 'bridge', 'docker', 'hyper-v', 'mihomo', 'radmin', 'tailscale', 'tap', 'tun',\n 'utun', 'vbox', 'veth', 'virtual', 'vmware', 'vpn', 'vethernet', 'wsl', 'zerotier',\n]\n\nfunction requiredString(value: unknown, name: string): string {\n if (typeof value !== 'string' || value.length === 0) throw new Error(`${name} must be a non-empty string`)\n return value\n}\n\n/** Validate the durable managed setup before it controls network and filesystem operations. */\nexport function parseManagedSetup(value: unknown): ManagedSetup {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw new Error('mobile setup file must be an object')\n }\n const record = value as Record<string, unknown>\n if (record.version !== 2 || Reflect.ownKeys(record)\n .some(key => typeof key !== 'string' || !['version', 'networkInterface', 'listenPort', 'upstreamOrigin', 'tls'].includes(key))) {\n throw new Error('mobile setup file has an unsupported format')\n }\n if (!Number.isSafeInteger(record.listenPort) || (record.listenPort as number) < 1024\n || (record.listenPort as number) > 65535) {\n throw new Error('mobile setup listenPort must be from 1024 through 65535')\n }\n if (typeof record.tls !== 'object' || record.tls === null || Array.isArray(record.tls)) {\n throw new Error('mobile setup tls must be an object')\n }\n const tls = record.tls as Record<string, unknown>\n if (tls.mode !== 'managed' || Reflect.ownKeys(tls)\n .some(key => typeof key !== 'string' || !['mode', 'caCertFile', 'caKeyFile', 'certFile', 'keyFile'].includes(key))) {\n throw new Error('mobile setup tls has an unsupported format')\n }\n return Object.freeze({\n version: 2,\n networkInterface: requiredString(record.networkInterface, 'mobile setup networkInterface'),\n listenPort: record.listenPort as number,\n upstreamOrigin: requiredString(record.upstreamOrigin, 'mobile setup upstreamOrigin'),\n tls: Object.freeze({\n mode: 'managed',\n caCertFile: requiredString(tls.caCertFile, 'mobile setup tls.caCertFile'),\n caKeyFile: requiredString(tls.caKeyFile, 'mobile setup tls.caKeyFile'),\n certFile: requiredString(tls.certFile, 'mobile setup tls.certFile'),\n keyFile: requiredString(tls.keyFile, 'mobile setup tls.keyFile'),\n }),\n })\n}\n\nfunction privateIpv4(value: string): boolean {\n const parts = value.split('.').map(Number)\n return parts.length === 4 && parts.every(part => Number.isInteger(part) && part >= 0 && part <= 255)\n && (parts[0] === 10\n || (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31)\n || (parts[0] === 192 && parts[1] === 168))\n}\n\nfunction networkCidr(address: string, cidr: string): string {\n const prefix = Number(cidr.slice(cidr.lastIndexOf('/') + 1))\n const value = address.split('.').reduce((total, part) => ((total << 8) | Number(part)) >>> 0, 0)\n const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0\n const network = (value & mask) >>> 0\n return `${[24, 16, 8, 0].map(shift => (network >>> shift) & 255).join('.')}/${String(prefix)}`\n}\n\n/** List current private IPv4 candidates with their interface identity. */\nexport function availableLanNetworks(table: InterfaceTable = networkInterfaces()): LanNetwork[] {\n const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? [])\n .filter(entry => entry.family === 'IPv4' && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null)\n .map(entry => ({ name, address: entry.address, cidr: networkCidr(entry.address, entry.cidr!) })))\n return [...new Map(candidates.map(entry => [`${entry.name}\\0${entry.address}`, entry])).values()]\n}\n\nfunction likelyVirtualInterface(name: string): boolean {\n const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, ' ')\n return VIRTUAL_INTERFACE_MARKERS.some(marker => normalized.includes(marker.replaceAll('-', ' ')))\n || /^(?:br|wg)\\d*\\b/u.test(normalized)\n}\n\nasync function runRouteCommand(file: string, args: readonly string[]): Promise<string> {\n const result = await execFile(file, [...args], { encoding: 'utf8', windowsHide: true })\n return result.stdout\n}\n\nfunction uniqueLines(output: string): string[] {\n return [...new Set(output.split(/\\r?\\n/gu).map(line => line.trim()).filter(Boolean))]\n}\n\n/** Return operating-system default-route interfaces in routing preference order. */\nexport async function preferredLanInterfaceNames(\n platform: NodeJS.Platform = process.platform,\n run: RouteCommand = runRouteCommand,\n): Promise<string[]> {\n try {\n if (platform === 'win32') {\n const script = [\n \"$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop\",\n \"$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {\",\n ' $route = $_',\n ' $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue',\n ' $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue',\n \" if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {\",\n ' [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }',\n ' }',\n '}',\n '$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique',\n ].join('; ')\n return uniqueLines(await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]))\n }\n if (platform === 'linux') {\n const routes = uniqueLines(await run('ip', ['-o', '-4', 'route', 'show', 'default']))\n .map(line => ({\n name: /(?:^|\\s)dev\\s+(\\S+)/u.exec(line)?.[1],\n metric: Number(/(?:^|\\s)metric\\s+(\\d+)/u.exec(line)?.[1] ?? 0),\n }))\n .filter((route): route is { name: string; metric: number } => route.name !== undefined\n && !likelyVirtualInterface(route.name))\n .sort((left, right) => left.metric - right.metric)\n return [...new Set(routes.map(route => route.name))]\n }\n if (platform === 'darwin') {\n const name = /^\\s*interface:\\s*(\\S+)\\s*$/mu.exec(await run('route', ['-n', 'get', 'default']))?.[1]\n return name === undefined || likelyVirtualInterface(name) ? [] : [name]\n }\n } catch {\n // Route discovery is advisory; deterministic candidate checks below remain the fallback.\n }\n return []\n}\n\n/** Select an active LAN, optionally by address or by a previously saved interface name. */\nexport function selectLanNetwork(\n requestedAddress?: string,\n requestedInterface?: string,\n table?: InterfaceTable,\n preferredInterfaces: readonly string[] = [],\n): LanNetwork {\n const candidates = availableLanNetworks(table)\n if (requestedAddress !== undefined) {\n const match = candidates.find(candidate => candidate.address === requestedAddress)\n if (match === undefined) throw new Error(`--address ${requestedAddress} is not an active private LAN address`)\n return match\n }\n if (requestedInterface !== undefined) {\n const matches = candidates.filter(candidate => candidate.name === requestedInterface)\n if (matches.length === 1) return matches[0]!\n if (matches.length === 0) {\n throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`)\n }\n throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`)\n }\n if (candidates.length === 1) return candidates[0]!\n if (candidates.length === 0) throw new Error('no active private LAN address was found; connect to Wi-Fi or Ethernet')\n for (const name of preferredInterfaces) {\n const matches = candidates.filter(candidate => candidate.name === name)\n if (matches.length === 1) return matches[0]!\n }\n const physicalCandidates = candidates.filter(candidate => !likelyVirtualInterface(candidate.name))\n if (physicalCandidates.length === 1) return physicalCandidates[0]!\n throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map(entry => `${entry.name}=${entry.address}`).join(', ')}`)\n}\n\nfunction assertMatchingCa(certPem: string, keyPem: string): X509Certificate {\n const certificate = new X509Certificate(certPem)\n if (!certificate.ca || certificate.subject !== certificate.issuer\n || !certificate.verify(certificate.publicKey)) {\n throw new Error('managed TLS CA must be a self-signed CA certificate')\n }\n const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({ format: 'der', type: 'spki' })\n const certificatePublic = certificate.publicKey.export({ format: 'der', type: 'spki' })\n if (!privatePublic.equals(certificatePublic)) throw new Error('managed TLS CA certificate and key do not match')\n if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) {\n throw new Error('managed TLS CA certificate is not currently valid')\n }\n return certificate\n}\n\nasync function atomicWrite(file: string, contents: string | Uint8Array): Promise<void> {\n const directory = dirname(file)\n await mkdir(directory, { recursive: true, mode: 0o700 })\n const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`)\n await writeFile(temporary, contents, { mode: 0o600 })\n await rename(temporary, file)\n await restrictPrivateFile(file)\n}\n\n/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */\nexport async function ensureManagedCa(\n setup: ManagedSetup['tls'],\n legacy?: { readonly certFile: string; readonly keyFile: string },\n): Promise<X509Certificate> {\n let certPem: string | undefined\n let keyPem: string | undefined\n try {\n [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, 'utf8'), readFile(setup.caKeyFile, 'utf8')])\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n let migrated = false\n if (legacy !== undefined) {\n try {\n [certPem, keyPem] = await Promise.all([readFile(legacy.certFile, 'utf8'), readFile(legacy.keyFile, 'utf8')])\n assertMatchingCa(certPem, keyPem)\n migrated = true\n } catch (legacyError) {\n if ((legacyError as NodeJS.ErrnoException).code !== 'ENOENT') throw legacyError\n }\n }\n if (!migrated) {\n const now = new Date()\n const notAfter = new Date(now)\n notAfter.setFullYear(notAfter.getFullYear() + 5)\n const generated = await generate([{ name: 'commonName', value: 'DeepSeek Harness Mobile CA' }], {\n keyType: 'ec',\n curve: 'P-256',\n algorithm: 'sha256',\n notBeforeDate: new Date(now.getTime() - 5 * 60_000),\n notAfterDate: notAfter,\n extensions: [\n { name: 'basicConstraints', cA: true, critical: true },\n { name: 'keyUsage', digitalSignature: true, keyCertSign: true, cRLSign: true, critical: true },\n ],\n })\n certPem = generated.cert\n keyPem = generated.private\n }\n if (certPem === undefined || keyPem === undefined) throw new Error('managed TLS CA creation did not produce key material')\n await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)])\n }\n if (certPem === undefined || keyPem === undefined) throw new Error('managed TLS CA creation did not produce key material')\n await Promise.all([restrictPrivateFile(setup.caCertFile), restrictPrivateFile(setup.caKeyFile)])\n return assertMatchingCa(certPem, keyPem)\n}\n\n/** Sign and atomically install a server leaf for the interface's current address. */\nexport async function refreshManagedServerCertificate(setup: ManagedSetup, address: string): Promise<void> {\n await Promise.all([restrictPrivateFile(setup.tls.caCertFile), restrictPrivateFile(setup.tls.caKeyFile)])\n const [caCert, caKey] = await Promise.all([\n readFile(setup.tls.caCertFile, 'utf8'),\n readFile(setup.tls.caKeyFile, 'utf8'),\n ])\n assertMatchingCa(caCert, caKey)\n const now = new Date()\n const notAfter = new Date(now)\n notAfter.setDate(notAfter.getDate() + 397)\n const server = await generate([{ name: 'commonName', value: 'DeepSeek Harness Mobile' }], {\n keyType: 'ec',\n curve: 'P-256',\n algorithm: 'sha256',\n notBeforeDate: new Date(now.getTime() - 5 * 60_000),\n notAfterDate: notAfter,\n ca: { cert: caCert, key: caKey },\n extensions: [\n { name: 'basicConstraints', cA: false, critical: true },\n { name: 'keyUsage', digitalSignature: true, critical: true },\n { name: 'extKeyUsage', serverAuth: true },\n { name: 'subjectAltName', altNames: [{ type: 7, ip: address }] },\n ],\n })\n await Promise.all([\n atomicWrite(setup.tls.certFile, server.cert),\n atomicWrite(setup.tls.keyFile, server.private),\n ])\n}\n\n/** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */\nexport async function materializeManagedSetup(\n setup: ManagedSetup,\n table?: InterfaceTable,\n): Promise<Record<string, unknown>> {\n const network = selectLanNetwork(undefined, setup.networkInterface, table)\n await refreshManagedServerCertificate(setup, network.address)\n const ca = new X509Certificate(await readFile(setup.tls.caCertFile, 'utf8'))\n return {\n publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,\n listenHost: network.address,\n upstreamOrigin: setup.upstreamOrigin,\n allowedCidrs: [network.cidr],\n instanceId: ca.fingerprint256.replaceAll(':', '').toLowerCase(),\n pairingCaFile: setup.tls.caCertFile,\n tls: {\n mode: 'provided',\n certFile: setup.tls.certFile,\n keyFile: setup.tls.keyFile,\n },\n }\n}\n","import { createHash } from 'node:crypto'\nimport type { Dirent } from 'node:fs'\nimport { lstat, mkdir, opendir, readFile, realpath } from 'node:fs/promises'\nimport { basename, isAbsolute, join, relative, resolve } from 'node:path'\nimport { pathToFileURL } from 'node:url'\nimport { Service, type Context } from '@deepseek-ai/cordis'\nimport z from '@deepseek-ai/schemastery'\nimport { finished, type Readable } from 'node:stream'\n\n/** Maximum sizes enforced at the local-extension filesystem boundary. */\nexport const EXTENSION_LIMITS = Object.freeze({\n manifest: 64 * 1024,\n script: 1024 * 1024,\n css: 512 * 1024,\n asset: 8 * 1024 * 1024,\n assetFiles: 256,\n assetBytes: 32 * 1024 * 1024,\n assetDepth: 8,\n})\n\n/** A misbehaving host activation must not wedge the local watcher forever. */\nconst HOST_ACTIVATION_TIMEOUT_MS = 5_000\n\n/** The previous Host outlives the hidden-page refresh interval and one timed refresh. */\nconst RETIRED_GENERATION_TTL_MS = 10 * 60_000\n\n/** Extension teardown is advisory and must never stop watcher progress. */\nconst HOST_TEARDOWN_TIMEOUT_MS = 2_000\n\nasync function withActivationTimeout<T>(promise: Promise<T>, id: string, signal: AbortSignal): Promise<T> {\n let timer: NodeJS.Timeout | undefined\n let onAbort: (() => void) | undefined\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => reject(new MobileExtensionError('host_load_timeout', `extension ${id} activation timed out`, 500)), HOST_ACTIVATION_TIMEOUT_MS)\n }),\n new Promise<never>((_, reject) => {\n const abort = (): void => { reject(new MobileExtensionError('host_activation_closed', `extension ${id} activation is closed`, 409)) }\n if (signal.aborted) abort()\n else { onAbort = abort; signal.addEventListener('abort', abort, { once: true }) }\n }),\n ])\n } finally {\n if (timer !== undefined) clearTimeout(timer)\n if (onAbort !== undefined) signal.removeEventListener('abort', onAbort)\n }\n}\n\n/** A controlled business failure returned by an extension action or route. */\nexport class MobileExtensionError extends Error {\n constructor(readonly code: string, message: string, readonly status = 400) {\n super(message)\n this.name = 'MobileExtensionError'\n }\n}\n\n/** One host-side action exposed by an extension. */\nexport interface MobileHostAction {\n readonly input?: { parse(value: unknown): unknown }\n readonly run: (context: MobileActionContext, input: unknown) => unknown | Promise<unknown>\n}\n\n/** Context supplied to a host action. */\nexport interface MobileActionContext {\n readonly signal: AbortSignal\n readonly deviceId: string\n}\n\n/** Safe request values supplied to a host route. */\nexport interface MobileRouteRequest {\n readonly method: string\n readonly pathname: string\n readonly query: Readonly<URLSearchParams>\n readonly headers: Readonly<Record<string, string>>\n readonly body: Uint8Array\n readonly signal: AbortSignal\n readonly deviceId: string\n}\n\n/** Values an extension route may return; status is a final HTTP code from 200 through 599. */\nexport interface MobileRouteResponse {\n readonly status?: number\n readonly contentType?: string\n readonly headers?: Readonly<Record<string, string>>\n readonly body: string | Uint8Array | Readable\n}\n\n/** One host-side route exposed by an extension. */\nexport interface MobileHostRoute {\n readonly method: string\n readonly path: string\n readonly kind?: 'exact' | 'prefix'\n readonly handle: (request: MobileRouteRequest) => MobileRouteResponse | Promise<MobileRouteResponse>\n}\n\n/** Metadata shared by local and npm-provided extensions. */\nexport interface MobileExtensionManifest {\n readonly schemaVersion: 1\n readonly id: string\n readonly name: string\n readonly version: string\n readonly description?: string\n}\n\n/** Definition registered by a normal Cordis plugin. */\nexport interface MobileExtensionDefinition extends MobileExtensionManifest {\n readonly actions?: Readonly<Record<string, MobileHostAction>>\n readonly routes?: readonly MobileHostRoute[]\n}\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n mobileAccess: MobileAccessService\n }\n}\n\n/** A local extension manifest read from extension.json. */\nexport interface LocalExtensionManifest extends MobileExtensionManifest {}\n\n/** Public snapshot sent to the mobile browser. */\nexport interface MobileExtensionClientEntry extends MobileExtensionManifest {\n readonly generation?: string\n readonly scriptUrl?: string\n readonly styleUrl?: string\n readonly assetsUrl?: string\n}\n\ninterface LocalAssetSnapshot {\n readonly body: Buffer\n readonly digest: string\n readonly name: string\n}\n\n/** Small status summary used by the desktop mobile-access card. */\nexport interface MobileExtensionStatus {\n readonly loaded: number\n readonly failed: number\n}\n\ninterface ActiveLocalExtension {\n readonly manifest: LocalExtensionManifest\n readonly directory: string\n readonly scriptBody?: Buffer\n readonly styleBody?: Buffer\n readonly assets: ReadonlyMap<string, LocalAssetSnapshot>\n readonly host: MobileExtensionDefinition\n readonly controller: AbortController\n readonly cleanups: readonly (() => void | Promise<void>)[]\n readonly digest: string\n}\n\ninterface RegisteredExtension {\n readonly definition: MobileExtensionDefinition\n readonly dispose: () => void\n}\n\ninterface RetiredLocalExtension {\n readonly active: ActiveLocalExtension\n readonly timer: NodeJS.Timeout\n}\n\ntype HostApi = {\n readonly manifest: LocalExtensionManifest\n readonly context: Context\n readonly schema: typeof z\n readonly signal: AbortSignal\n action(name: string, spec: MobileHostAction): void\n route(spec: MobileHostRoute): void\n effect(setup: () => void | (() => void | Promise<void>) | Promise<void | (() => void | Promise<void>)>): void\n}\n\ntype LocalHostModule = { readonly default?: (api: HostApi) => void | Promise<void> }\n\n/** Validate user-facing extension text without allowing control characters. */\nfunction text(value: unknown, field: string, maximum: number, required: boolean): string | undefined {\n if (value === undefined && !required) return undefined\n if (typeof value !== 'string' || (required && value.length === 0) || value.length > maximum\n || /[\\u0000-\\u001f\\u007f]/u.test(value)) throw new MobileExtensionError('invalid_manifest', `${field} is invalid`)\n return value\n}\n\n/** Validate a stable extension id. */\nexport function assertExtensionId(value: unknown): string {\n if (typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(value)) {\n throw new MobileExtensionError('invalid_manifest', 'extension id is invalid')\n }\n return value\n}\n\n/** Validate a manifest from JSON or a plugin definition. */\nexport function parseExtensionManifest(value: unknown): LocalExtensionManifest {\n if (value === null || typeof value !== 'object' || Array.isArray(value)) {\n throw new MobileExtensionError('invalid_manifest', 'extension.json must be an object')\n }\n const record = value as Record<string, unknown>\n if (record.schemaVersion !== 1) throw new MobileExtensionError('invalid_manifest', 'unsupported extension schema')\n const id = assertExtensionId(record.id)\n const name = text(record.name, 'name', 120, true) as string\n const version = text(record.version, 'version', 64, true) as string\n const description = text(record.description, 'description', 500, false)\n for (const key of Reflect.ownKeys(record)) {\n if (!['schemaVersion', 'id', 'name', 'version', 'description'].includes(String(key))) {\n throw new MobileExtensionError('invalid_manifest', 'extension.json has unknown fields')\n }\n }\n return Object.freeze({ schemaVersion: 1, id, name, version, ...(description === undefined ? {} : { description }) })\n}\n\nfunction normalizeRelativePath(value: string, field: string): string {\n if (value.length === 0 || value.includes('\\0') || isAbsolute(value)) throw new MobileExtensionError('invalid_extension_path', `${field} is invalid`)\n const normalized = value.replaceAll('\\\\', '/')\n if (normalized.split('/').some(part => part === '' || part === '.' || part === '..')) {\n throw new MobileExtensionError('invalid_extension_path', `${field} escapes extension directory`)\n }\n return normalized\n}\n\nasync function regularFile(path: string, maximum: number, field: string): Promise<{ readonly path: string; readonly size: number }> {\n let info\n try { info = await lstat(path) } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new MobileExtensionError('invalid_extension', `${field} is missing`)\n throw error\n }\n if (!info.isFile() || info.isSymbolicLink() || info.size > maximum) {\n throw new MobileExtensionError('invalid_extension', `${field} must be a regular file within its size limit`)\n }\n return { path, size: info.size }\n}\n\nasync function containedPath(root: string, relativePath: string, maximum: number, field: string): Promise<{ readonly path: string; readonly size: number }> {\n const normalized = normalizeRelativePath(relativePath, field)\n const target = resolve(root, normalized)\n const rootReal = await realpath(root)\n const targetReal = await realpath(target)\n const relation = relative(rootReal, targetReal)\n if (relation === '' || relation.startsWith('..') || isAbsolute(relation)) throw new MobileExtensionError('invalid_extension_path', `${field} escapes extension directory`)\n return regularFile(targetReal, maximum, field)\n}\n\nasync function optionalFile(root: string, name: string, maximum: number, field: string): Promise<string | undefined> {\n try {\n return (await containedPath(root, name, maximum, field)).path\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined\n if (error instanceof MobileExtensionError && error.message.includes('is missing')) return undefined\n throw error\n }\n}\n\nasync function optionalBytes(root: string, name: string, maximum: number, field: string): Promise<Buffer | undefined> {\n const path = await optionalFile(root, name, maximum, field)\n return path === undefined ? undefined : readFile(path)\n}\n\nfunction assertRealPathWithin(rootReal: string, targetReal: string, field: string): void {\n const relation = relative(rootReal, targetReal)\n if (relation === '' || relation.startsWith('..') || isAbsolute(relation)) {\n throw new MobileExtensionError('invalid_extension_path', `${field} escapes extension directory`)\n }\n}\n\nasync function realExtensionRoot(directory: string): Promise<string> {\n const root = resolve(directory)\n const info = await lstat(root)\n if (!info.isDirectory() || info.isSymbolicLink()) throw new MobileExtensionError('invalid_extension', 'extension directory must be real')\n return realpath(root)\n}\n\nasync function assetSnapshot(extensionRootReal: string): Promise<ReadonlyMap<string, LocalAssetSnapshot>> {\n const assetsPath = join(extensionRootReal, 'assets')\n let assetsInfo\n try { assetsInfo = await lstat(assetsPath) } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map()\n throw error\n }\n if (!assetsInfo.isDirectory() || assetsInfo.isSymbolicLink()) {\n throw new MobileExtensionError('invalid_extension', 'assets must be a real directory')\n }\n const assetsReal = await realpath(assetsPath)\n assertRealPathWithin(extensionRootReal, assetsReal, 'assets')\n const snapshots = new Map<string, LocalAssetSnapshot>()\n let totalBytes = 0\n const visit = async (directoryReal: string, prefix: string, depth: number): Promise<void> => {\n if (depth > EXTENSION_LIMITS.assetDepth) {\n throw new MobileExtensionError('invalid_extension', 'asset tree exceeds its depth limit')\n }\n assertRealPathWithin(extensionRootReal, directoryReal, 'asset directory')\n const handle = await opendir(directoryReal)\n const entries: Dirent[] = []\n try { for await (const entry of handle) entries.push(entry) }\n finally { await handle.close().catch(() => undefined) }\n entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)\n for (const entry of entries) {\n const path = join(directoryReal, entry.name)\n const info = await lstat(path)\n if (info.isSymbolicLink()) throw new MobileExtensionError('invalid_extension_path', 'asset escapes extension directory')\n const targetReal = await realpath(path)\n assertRealPathWithin(extensionRootReal, targetReal, 'asset')\n const key = prefix === '' ? entry.name : `${prefix}/${entry.name}`\n if (info.isDirectory()) {\n await visit(targetReal, key, depth + 1)\n continue\n }\n if (!info.isFile() || info.size > EXTENSION_LIMITS.asset) {\n throw new MobileExtensionError('invalid_extension', 'asset must be a regular file within its size limit')\n }\n const body = await readFile(targetReal)\n totalBytes += body.byteLength\n if (snapshots.size >= EXTENSION_LIMITS.assetFiles || totalBytes > EXTENSION_LIMITS.assetBytes) {\n throw new MobileExtensionError('invalid_extension', 'asset tree exceeds its aggregate limit')\n }\n snapshots.set(key, Object.freeze({ body, digest: createHash('sha256').update(body).digest('hex'), name: entry.name }))\n }\n }\n await visit(assetsReal, '', 0)\n return snapshots\n}\n\ninterface LocalExtensionFingerprint {\n readonly manifest: LocalExtensionManifest\n readonly digest: string\n readonly scriptBody?: Buffer\n readonly styleBody?: Buffer\n readonly assets: ReadonlyMap<string, LocalAssetSnapshot>\n}\n\nasync function extensionFingerprint(directory: string): Promise<LocalExtensionFingerprint> {\n const root = await realExtensionRoot(directory)\n const manifestFile = await regularFile(join(root, 'extension.json'), EXTENSION_LIMITS.manifest, 'extension.json')\n const manifestBody = await readFile(manifestFile.path)\n const manifest = parseExtensionManifest(JSON.parse(manifestBody.toString('utf8')) as unknown)\n if (manifest.id !== basename(root)) throw new MobileExtensionError('invalid_manifest', 'extension id must match its directory name')\n const [host, script, style, assets] = await Promise.all([\n optionalBytes(root, 'host.mjs', EXTENSION_LIMITS.script, 'host.mjs'),\n optionalBytes(root, 'mobile.js', EXTENSION_LIMITS.script, 'mobile.js'),\n optionalBytes(root, 'mobile.css', EXTENSION_LIMITS.css, 'mobile.css'),\n assetSnapshot(root),\n ])\n const digest = createHash('sha256').update(`manifest:${manifestBody.byteLength}:`).update(createHash('sha256').update(manifestBody).digest())\n for (const [name, body] of [['host', host], ['script', script], ['style', style]] as const) {\n digest.update(`\\0${name}:${body?.byteLength ?? -1}:`)\n if (body !== undefined) digest.update(createHash('sha256').update(body).digest())\n }\n for (const [name, asset] of assets) {\n digest.update(`\\0asset:${Buffer.byteLength(name)}:${name}:${asset.body.byteLength}:${asset.digest}`)\n }\n return {\n manifest,\n digest: digest.digest('hex'),\n assets,\n ...(script === undefined ? {} : { scriptBody: script }),\n ...(style === undefined ? {} : { styleBody: style }),\n }\n}\n\nfunction routeKey(route: MobileHostRoute): string {\n const method = route.method.toUpperCase()\n const path = normalizeRoutePath(route.path)\n return `${method} ${route.kind ?? 'exact'} ${path}`\n}\n\nfunction normalizeRoutePath(value: string): string {\n if (typeof value !== 'string' || value.length === 0 || value.length > 256\n || value.includes('?') || value.includes('#') || value.includes('\\\\') || value.includes('\\0')\n || /[\\u0000-\\u001f\\u007f]/u.test(value)) throw new MobileExtensionError('invalid_route', 'extension route path is invalid')\n const normalizedInput = value.startsWith('/') ? value : `/${value}`\n const parts = normalizedInput.split('/')\n if (parts.some(part => part === '..' || part === '.')) throw new MobileExtensionError('invalid_route', 'extension route path is invalid')\n return normalizedInput === '/' ? '/' : normalizedInput.replace(/\\/+$/u, '')\n}\n\nfunction validateDefinition(definition: MobileExtensionDefinition): MobileExtensionDefinition {\n const manifest = parseExtensionManifest({\n schemaVersion: definition.schemaVersion,\n id: definition.id,\n name: definition.name,\n version: definition.version,\n ...(definition.description === undefined ? {} : { description: definition.description }),\n })\n const actionNames = new Set<string>()\n for (const [name, action] of Object.entries(definition.actions ?? {})) {\n if (!/^[a-z][a-z0-9-]{0,63}$/u.test(name) || action === null || typeof action !== 'object' || typeof action.run !== 'function' || actionNames.has(name)) {\n throw new MobileExtensionError('invalid_action', `invalid action ${name}`)\n }\n actionNames.add(name)\n }\n const routeNames = new Set<string>()\n const routes = (definition.routes ?? []).map(route => {\n if (route === null || typeof route !== 'object' || typeof route.handle !== 'function') throw new MobileExtensionError('invalid_route', 'invalid extension route')\n const method = route.method.toUpperCase()\n if (!['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) throw new MobileExtensionError('invalid_route', 'unsupported extension route method')\n const normalized: MobileHostRoute = { ...route, method, path: normalizeRoutePath(route.path) }\n const key = routeKey(normalized)\n if (routeNames.has(key)) throw new MobileExtensionError('duplicate_route', `duplicate route ${key}`)\n routeNames.add(key)\n return normalized\n })\n return Object.freeze({ ...manifest, ...(definition.actions === undefined ? {} : { actions: Object.freeze({ ...definition.actions }) }), ...(routes.length === 0 ? {} : { routes: Object.freeze(routes) }) })\n}\n\ninterface CombinedSignalLifetime {\n readonly signal: AbortSignal\n readonly cleanup: () => void\n}\n\nfunction combineSignalLifetime(first: AbortSignal, second: AbortSignal): CombinedSignalLifetime {\n if (first.aborted || second.aborted) {\n const aborted = new AbortController()\n aborted.abort(first.aborted ? first.reason : second.reason)\n return { signal: aborted.signal, cleanup: () => undefined }\n }\n const controller = new AbortController()\n const cleanup = (): void => {\n first.removeEventListener('abort', abortFirst)\n second.removeEventListener('abort', abortSecond)\n }\n const abortFirst = (): void => { cleanup(); controller.abort(first.reason) }\n const abortSecond = (): void => { cleanup(); controller.abort(second.reason) }\n first.addEventListener('abort', abortFirst, { once: true })\n second.addEventListener('abort', abortSecond, { once: true })\n return { signal: controller.signal, cleanup }\n}\n\n/** Combine two abort lifetimes without relying on AbortSignal.any in older WebViews. */\nexport function combineSignals(first: AbortSignal, second: AbortSignal): AbortSignal {\n return combineSignalLifetime(first, second).signal\n}\n\n/** Host registry and service consumed by both npm plugins and local extensions. */\nexport class MobileAccessService extends Service {\n private readonly registered = new Map<string, RegisteredExtension>()\n private readonly local = new Map<string, ActiveLocalExtension>()\n private readonly retired = new Map<string, RetiredLocalExtension>()\n private readonly failures = new Map<string, string>()\n private readonly contentListeners = new Set<() => void>()\n private contentHash = createHash('sha256').update('').digest('hex')\n private localRoot: string | undefined\n private localContext: Context | undefined\n private localTimer: NodeJS.Timeout | undefined\n private localRefreshing: Promise<void> | undefined\n private localRefreshAbort: AbortController | undefined\n private localLifecycle = 0\n private localClosed = true\n\n constructor(ctx: Context) { super(ctx, 'mobileAccess') }\n\n /** Register a normal Cordis extension and return an idempotent disposer. */\n registerExtension(definition: MobileExtensionDefinition): () => void {\n const validated = validateDefinition(definition)\n if (this.registered.has(validated.id) || this.local.has(validated.id)) throw new Error(`mobile extension id already registered: ${validated.id}`)\n const dispose = (): void => {\n const current = this.registered.get(validated.id)\n if (current?.dispose === dispose) {\n this.registered.delete(validated.id)\n this.updateContentHash()\n }\n }\n this.registered.set(validated.id, { definition: validated, dispose })\n this.updateContentHash()\n return dispose\n }\n\n /** Aggregate digest covering every registered and active local extension. */\n contentDigest(): string {\n return this.contentHash\n }\n\n /** Subscribe to committed extension generation changes. */\n onContentChanged(listener: () => void): () => void {\n this.contentListeners.add(listener)\n return () => { this.contentListeners.delete(listener) }\n }\n\n private updateContentHash(): void {\n const parts = [\n ...[...this.registered.values()].map(entry => entry.definition.id),\n ...[...this.local.values()].map(active => `${active.manifest.id}:${active.digest}`),\n ]\n const next = createHash('sha256').update(parts.sort().join('|')).digest('hex')\n if (next === this.contentHash) return\n this.contentHash = next\n for (const listener of this.contentListeners) {\n try { listener() } catch { /* One observer cannot block a committed generation. */ }\n }\n }\n\n /** Return the current client-facing manifest, deterministically sorted by id. */\n manifest(): readonly MobileExtensionClientEntry[] {\n const entries = new Map<string, MobileExtensionClientEntry>()\n for (const { definition } of this.registered.values()) entries.set(definition.id, {\n schemaVersion: 1, id: definition.id, name: definition.name, version: definition.version,\n ...(definition.description === undefined ? {} : { description: definition.description }),\n })\n for (const active of this.local.values()) entries.set(active.manifest.id, {\n ...active.manifest,\n generation: active.digest,\n ...(active.scriptBody === undefined ? {} : { scriptUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.js?generation=${active.digest}` }),\n ...(active.styleBody === undefined ? {} : { styleUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.css?generation=${active.digest}` }),\n assetsUrl: `/mobile-access/extensions/${active.manifest.id}/assets/`,\n })\n return [...entries.values()].sort((left, right) => left.id.localeCompare(right.id))\n }\n\n /** Return loaded and failed local extension counts without exposing host errors. */\n status(): MobileExtensionStatus {\n return Object.freeze({ loaded: this.registered.size + this.local.size, failed: this.failures.size })\n }\n\n /** Locate one active extension. */\n extension(id: string, generation?: string): MobileExtensionDefinition | ActiveLocalExtension | undefined {\n if (generation !== undefined) {\n const current = this.local.get(id)\n if (current?.digest === generation) return current\n const previous = this.retired.get(id)?.active\n return previous?.digest === generation ? previous : undefined\n }\n return this.local.get(id) ?? this.registered.get(id)?.definition\n }\n\n /** Return the active local generation signal for gateway cancellation wiring. */\n signal(id: string, generation?: string): AbortSignal | undefined {\n const extension = this.extension(id, generation)\n return extension !== undefined && 'host' in extension ? extension.controller.signal : undefined\n }\n\n /** Read a local client entry after validating that it remains inside its directory. */\n async readClientFile(id: string, kind: 'script' | 'style', signal?: AbortSignal, generation?: string): Promise<{ readonly body: Buffer; readonly digest: string }> {\n signal?.throwIfAborted()\n const selected = this.extension(id, generation)\n const active = selected !== undefined && 'host' in selected ? selected : undefined\n if (active === undefined) throw new MobileExtensionError('extension_generation_not_found', 'extension generation not found', 404)\n const snapshot = kind === 'script' ? active.scriptBody : active.styleBody\n if (snapshot === undefined) throw new MobileExtensionError('extension_asset_not_found', 'extension asset not found', 404)\n const body = Buffer.from(snapshot)\n return { body, digest: createHash('sha256').update(body).digest('hex') }\n }\n\n /** Read a generation-pinned static asset from its validated snapshot. */\n async readAsset(id: string, assetPath: string, signal?: AbortSignal, generation?: string): Promise<{ readonly body: Buffer; readonly digest: string; readonly name: string }> {\n signal?.throwIfAborted()\n const selected = this.extension(id, generation)\n const active = selected !== undefined && 'host' in selected ? selected : undefined\n if (active === undefined) throw new MobileExtensionError('extension_generation_not_found', 'extension generation not found', 404)\n const normalized = normalizeRelativePath(assetPath, 'asset')\n const asset = active.assets.get(normalized)\n if (asset === undefined) throw new MobileExtensionError('extension_asset_not_found', 'extension asset not found', 404)\n return { body: Buffer.from(asset.body), digest: asset.digest, name: asset.name }\n }\n\n /** Invoke one action after parsing its input and binding the request lifetime. */\n async invoke(id: string, actionName: string, input: unknown, context: MobileActionContext, generation?: string): Promise<unknown> {\n const extension = this.extension(id, generation)\n if (extension === undefined) throw new MobileExtensionError('extension_not_found', 'extension not found', 404)\n const definition = 'host' in extension ? extension.host : extension\n const action = definition.actions?.[actionName]\n if (action === undefined) throw new MobileExtensionError('action_not_found', 'action not found', 404)\n let parsed = input\n try { parsed = action.input?.parse(input) ?? input } catch { throw new MobileExtensionError('invalid_action_input', 'action input is invalid', 400) }\n const lifetime = 'host' in extension ? combineSignalLifetime(extension.controller.signal, context.signal) : undefined\n const signal = lifetime?.signal ?? context.signal\n try { return await action.run({ ...context, signal }, parsed) } catch (error) {\n if (error instanceof MobileExtensionError) throw error\n throw new MobileExtensionError('extension_failed', 'extension action failed', 500)\n } finally { lifetime?.cleanup() }\n }\n\n /** Match one route and invoke it with a generation-bound abort signal. */\n async route(id: string, method: string, pathname: string, request: MobileRouteRequest, generation?: string): Promise<MobileRouteResponse> {\n const extension = this.extension(id, generation)\n if (extension === undefined) throw new MobileExtensionError('extension_not_found', 'extension not found', 404)\n const definition = 'host' in extension ? extension.host : extension\n const route = definition.routes?.find((candidate: MobileHostRoute) => {\n if (candidate.method !== method) return false\n return (candidate.kind ?? 'exact') === 'exact'\n ? candidate.path === pathname\n : pathname === candidate.path || pathname.startsWith(`${candidate.path}/`)\n })\n if (route === undefined) throw new MobileExtensionError('route_not_found', 'route not found', 404)\n const lifetime = 'host' in extension ? combineSignalLifetime(extension.controller.signal, request.signal) : undefined\n let releaseLifetime = true\n try {\n const routeRequest = lifetime === undefined ? request : { ...request, signal: lifetime.signal }\n const result = await route.handle(routeRequest)\n if (result === null || typeof result !== 'object' || typeof result.body !== 'string' && !(result.body instanceof Uint8Array) && !isReadable(result.body)) {\n throw new MobileExtensionError('invalid_route_response', 'extension returned an invalid response', 500)\n }\n if (lifetime !== undefined && isReadable(result.body)) {\n releaseLifetime = false\n releaseSignalLifetimeWhenStreamSettles(result.body, lifetime.cleanup)\n }\n return result\n } catch (error) {\n if (error instanceof MobileExtensionError) throw error\n throw new MobileExtensionError('extension_failed', 'extension route failed', 500)\n } finally { if (releaseLifetime) lifetime?.cleanup() }\n }\n\n /** Start the local directory watcher; an absent directory is intentionally inert. */\n async startLocal(root: string, context: Context): Promise<void> {\n const targetRoot = resolve(root)\n if (this.localRoot !== undefined && resolve(this.localRoot) !== targetRoot) await this.stopLocal()\n if (this.localTimer !== undefined) clearInterval(this.localTimer)\n const lifecycle = ++this.localLifecycle\n this.localRoot = targetRoot; this.localContext = context; this.localClosed = false\n await mkdir(this.localRoot, { recursive: true })\n if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return\n await this.refreshLocal()\n if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return\n this.localTimer = setInterval(() => { void this.refreshLocal() }, 2_000)\n this.localTimer.unref()\n }\n\n /** Stop the watcher and abort every local host generation. */\n async stopLocal(): Promise<void> {\n this.localClosed = true\n const lifecycle = ++this.localLifecycle\n if (this.localTimer !== undefined) clearInterval(this.localTimer)\n this.localTimer = undefined\n const refreshing = this.localRefreshing\n this.localRefreshAbort?.abort()\n const previous = [...this.local.values(), ...[...this.retired.values()].map(entry => entry.active)]\n this.local.clear()\n for (const entry of this.retired.values()) clearTimeout(entry.timer)\n this.retired.clear()\n this.failures.clear()\n this.updateContentHash()\n await Promise.allSettled([\n abortAndDisposeLocal(previous),\n ...(refreshing === undefined ? [] : [refreshing]),\n ])\n if (this.localLifecycle !== lifecycle) return\n const late = [...this.local.values(), ...[...this.retired.values()].map(entry => entry.active)]\n this.local.clear()\n for (const entry of this.retired.values()) clearTimeout(entry.timer)\n this.retired.clear()\n this.failures.clear()\n this.updateContentHash()\n await abortAndDisposeLocal(late)\n if (this.localTimer !== undefined) clearInterval(this.localTimer)\n this.localTimer = undefined\n }\n\n /** Refresh all local extensions atomically; failures keep the previous snapshot. */\n refreshLocal(): Promise<void> {\n if (this.localRefreshing !== undefined) return this.localRefreshing\n const controller = new AbortController()\n this.localRefreshAbort = controller\n const refreshing = this.stageAndCommit(controller.signal).finally(() => {\n if (this.localRefreshing === refreshing) this.localRefreshing = undefined\n if (this.localRefreshAbort === controller) this.localRefreshAbort = undefined\n })\n this.localRefreshing = refreshing\n return refreshing\n }\n\n private async stageAndCommit(signal: AbortSignal): Promise<void> {\n if (this.localClosed || signal.aborted || this.localRoot === undefined || this.localContext === undefined) return\n let names: string[] = []\n try {\n const directory = await opendir(this.localRoot)\n try { for await (const entry of directory) if (entry.isDirectory() && !entry.isSymbolicLink()) names.push(entry.name) }\n finally { await directory.close().catch(() => undefined) }\n } catch { return }\n names.sort()\n const staged: ActiveLocalExtension[] = []\n const stagedFresh: ActiveLocalExtension[] = []\n let failingName = 'local'\n try {\n for (const name of names) {\n signal.throwIfAborted()\n failingName = name\n const directory = join(this.localRoot, name)\n const fingerprint = await extensionFingerprint(directory)\n const current = this.local.get(fingerprint.manifest.id)\n const retired = this.retired.get(fingerprint.manifest.id)?.active\n const previous = current?.digest === fingerprint.digest ? current : retired?.digest === fingerprint.digest ? retired : undefined\n if (previous?.digest === fingerprint.digest) staged.push(previous)\n else {\n const fresh = await loadLocalExtension(directory, this.localContext, fingerprint, signal)\n try {\n signal.throwIfAborted()\n const confirmed = await extensionFingerprint(directory)\n if (confirmed.digest !== fingerprint.digest) {\n throw new MobileExtensionError('extension_changed_during_activation', `extension ${fingerprint.manifest.id} changed during activation`, 409)\n }\n } catch (error) {\n await abortAndDisposeLocal([fresh])\n throw error\n }\n staged.push(fresh); stagedFresh.push(fresh)\n }\n }\n if (this.localClosed || signal.aborted || this.localRoot === undefined || this.localContext === undefined) {\n await abortAndDisposeLocal(stagedFresh)\n return\n }\n const duplicate = new Set<string>()\n for (const entry of staged) {\n if (duplicate.has(entry.manifest.id) || this.registered.has(entry.manifest.id)) throw new MobileExtensionError('duplicate_extension', `duplicate extension id ${entry.manifest.id}`)\n duplicate.add(entry.manifest.id)\n }\n const previous = [...this.local.values()]\n for (const entry of staged) {\n const retired = this.retired.get(entry.manifest.id)\n if (retired?.active === entry) {\n clearTimeout(retired.timer)\n this.retired.delete(entry.manifest.id)\n }\n }\n const stagedIds = new Set(staged.map(entry => entry.manifest.id))\n const removed: ActiveLocalExtension[] = []\n for (const entry of previous) {\n if (staged.includes(entry)) continue\n if (stagedIds.has(entry.manifest.id)) {\n this.retire(entry)\n continue\n }\n removed.push(entry)\n const retired = this.retired.get(entry.manifest.id)\n if (retired !== undefined) {\n clearTimeout(retired.timer)\n this.retired.delete(entry.manifest.id)\n removed.push(retired.active)\n }\n }\n this.local.clear()\n for (const entry of staged) this.local.set(entry.manifest.id, entry)\n for (const entry of staged) this.failures.delete(entry.manifest.id)\n for (const name of names) this.failures.delete(name)\n for (const failure of this.failures.keys()) {\n if (failure !== 'local' && !names.includes(failure)) this.failures.delete(failure)\n }\n this.failures.delete('local')\n if (removed.length > 0) void abortAndDisposeLocal(removed)\n this.updateContentHash()\n } catch (error) {\n await abortAndDisposeLocal(stagedFresh)\n if (this.localClosed || signal.aborted) return\n const message = error instanceof Error ? error.message : String(error)\n this.failures.set(failingName, message)\n if (!(error instanceof MobileExtensionError)) this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))\n }\n }\n\n private retire(active: ActiveLocalExtension): void {\n const previous = this.retired.get(active.manifest.id)\n if (previous?.active === active) return\n if (previous !== undefined) {\n clearTimeout(previous.timer)\n this.retired.delete(active.manifest.id)\n void abortAndDisposeLocal([previous.active])\n }\n const timer = setTimeout(() => {\n const current = this.retired.get(active.manifest.id)\n if (current?.active !== active) return\n this.retired.delete(active.manifest.id)\n void abortAndDisposeLocal([active])\n }, RETIRED_GENERATION_TTL_MS)\n timer.unref()\n this.retired.set(active.manifest.id, { active, timer })\n }\n}\n\nfunction isReadable(value: unknown): value is Readable {\n return value !== null && typeof value === 'object' && typeof (value as { pipe?: unknown }).pipe === 'function'\n}\n\nfunction releaseSignalLifetimeWhenStreamSettles(stream: Readable, cleanup: () => void): void {\n let stopObserving: (() => void) | undefined\n stopObserving = finished(stream, () => {\n stopObserving?.()\n cleanup()\n })\n}\n\nfunction invokeCleanups(cleanups: readonly (() => void | Promise<void>)[]): Promise<unknown>[] {\n const pending: Promise<unknown>[] = []\n for (const cleanup of [...cleanups].reverse()) {\n try { pending.push(Promise.resolve(cleanup())) } catch { /* extension teardown cannot block the owner */ }\n }\n return pending\n}\n\nasync function settleBounded(pending: readonly Promise<unknown>[], timeoutMs: number): Promise<void> {\n if (pending.length === 0) return\n let timer: NodeJS.Timeout | undefined\n await Promise.race([\n Promise.allSettled(pending),\n new Promise<void>(resolveTimeout => { timer = setTimeout(resolveTimeout, timeoutMs) }),\n ])\n if (timer !== undefined) clearTimeout(timer)\n}\n\nasync function abortAndDisposeLocal(entries: readonly ActiveLocalExtension[]): Promise<void> {\n const pending: Promise<unknown>[] = []\n for (const entry of entries) {\n entry.controller.abort()\n pending.push(...invokeCleanups(entry.cleanups))\n }\n await settleBounded(pending, HOST_TEARDOWN_TIMEOUT_MS)\n}\n\nasync function loadLocalExtension(directory: string, context: Context, known?: LocalExtensionFingerprint, parentSignal?: AbortSignal): Promise<ActiveLocalExtension> {\n const root = await realExtensionRoot(directory)\n const manifestFile = await regularFile(join(root, 'extension.json'), EXTENSION_LIMITS.manifest, 'extension.json')\n const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, 'utf8')) as unknown)\n if (manifest.id !== basename(root)) throw new MobileExtensionError('invalid_manifest', 'extension id must match its directory name')\n const scriptBody = known === undefined\n ? await optionalFile(root, 'mobile.js', EXTENSION_LIMITS.script, 'mobile.js').then(path => path === undefined ? undefined : readFile(path))\n : known.scriptBody\n const styleBody = known === undefined\n ? await optionalFile(root, 'mobile.css', EXTENSION_LIMITS.css, 'mobile.css').then(path => path === undefined ? undefined : readFile(path))\n : known.styleBody\n const assets = known?.assets ?? await assetSnapshot(root)\n const hostFile = await optionalFile(root, 'host.mjs', EXTENSION_LIMITS.script, 'host.mjs')\n const controller = new AbortController()\n const actions: Record<string, MobileHostAction> = {}\n const routes: MobileHostRoute[] = []\n const cleanups: (() => void | Promise<void>)[] = []\n const pendingEffects: Promise<void>[] = []\n let activationOpen = true\n const onParentAbort = (): void => { controller.abort(parentSignal?.reason) }\n if (parentSignal?.aborted === true) onParentAbort()\n else parentSignal?.addEventListener('abort', onParentAbort, { once: true })\n const ensureActivationOpen = (): void => {\n if (!activationOpen || controller.signal.aborted) throw new MobileExtensionError('host_activation_closed', `extension ${manifest.id} activation is closed`, 409)\n }\n const api: HostApi = {\n manifest,\n context,\n schema: z,\n signal: controller.signal,\n action(name, spec) { ensureActivationOpen(); if (actions[name] !== undefined) throw new MobileExtensionError('duplicate_action', `duplicate action ${name}`); actions[name] = spec },\n route(spec) { ensureActivationOpen(); routes.push(spec) },\n effect(setup) {\n ensureActivationOpen()\n const result = setup()\n if (result instanceof Promise) {\n pendingEffects.push(result.then(async cleanup => {\n if (typeof cleanup !== 'function') return\n if (activationOpen) cleanups.push(cleanup)\n else await cleanup()\n }))\n } else if (typeof result === 'function') {\n if (activationOpen) cleanups.push(result)\n else void Promise.resolve(result()).catch(() => undefined)\n }\n },\n }\n try {\n const activate = async (): Promise<void> => {\n controller.signal.throwIfAborted()\n if (hostFile !== undefined) {\n const digest = createHash('sha256').update(await readFile(hostFile)).digest('hex')\n controller.signal.throwIfAborted()\n let imported: LocalHostModule\n try { imported = await import(`${pathToFileURL(hostFile).href}?dsh_generation=${digest}`) as LocalHostModule }\n catch { throw new MobileExtensionError('host_load_failed', `could not load ${manifest.id}/host.mjs`, 500) }\n controller.signal.throwIfAborted()\n if (imported.default !== undefined) await imported.default(api)\n }\n await Promise.all(pendingEffects)\n }\n await withActivationTimeout(activate(), manifest.id, controller.signal)\n const host = validateDefinition({ ...manifest, actions, routes })\n activationOpen = false\n const digest = known?.digest ?? createHash('sha256').update(manifest.id).digest('hex')\n return Object.freeze({ manifest, directory: root, ...(scriptBody === undefined ? {} : { scriptBody }), ...(styleBody === undefined ? {} : { styleBody }), assets, host, controller, cleanups: Object.freeze(cleanups), digest })\n } catch (error) {\n activationOpen = false\n controller.abort()\n const cleanupPromises = invokeCleanups(cleanups.splice(0))\n await settleBounded([...pendingEffects, ...cleanupPromises], HOST_TEARDOWN_TIMEOUT_MS)\n throw error\n } finally {\n parentSignal?.removeEventListener('abort', onParentAbort)\n }\n}\n\n/** Construct the service in a Cordis plugin without importing DSH internals. */\nexport function createMobileAccessService(ctx: Context): MobileAccessService {\n return new MobileAccessService(ctx)\n}\n","#!/usr/bin/env node\nimport { execFileText as execFile } from './exec-file.js'\nimport { mkdir, readFile, rm, writeFile } from 'node:fs/promises'\nimport { homedir } from 'node:os'\nimport { join, resolve } from 'node:path'\nimport {\n ensureManagedCa,\n preferredLanInterfaceNames,\n refreshManagedServerCertificate,\n selectLanNetwork,\n type ManagedSetup,\n} from './managed-setup.js'\nimport { assertExtensionId } from './extensions.js'\nimport { restrictPrivateFile } from './private-file.js'\n\ninterface SetupOptions {\n readonly address?: string\n readonly port: number\n readonly dshPort: number\n readonly configureFirewall: boolean\n}\n\nconst FIREWALL_TCP_RULE = 'DSH Mobile HTTPS'\nconst FIREWALL_UDP_RULE = 'DSH Mobile Discovery'\n\nfunction parseOptions(args: readonly string[]): SetupOptions {\n let address: string | undefined\n let port = 3443\n let dshPort = 3080\n let configureFirewall = true\n for (let index = 0; index < args.length; index += 1) {\n const name = args[index]\n const value = args[index + 1]\n if (name === '--address' && value !== undefined) {\n address = value\n index += 1\n continue\n }\n if (name === '--port' && value !== undefined) {\n port = Number(value)\n index += 1\n continue\n }\n if (name === '--dsh-port' && value !== undefined) {\n dshPort = Number(value)\n index += 1\n continue\n }\n if (name === '--no-firewall') {\n configureFirewall = false\n continue\n }\n throw new Error(`unknown setup option: ${name ?? ''}`)\n }\n if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw new Error('--port must be from 1024 through 65535')\n if (!Number.isSafeInteger(dshPort) || dshPort < 1024 || dshPort > 65535) throw new Error('--dsh-port must be from 1024 through 65535')\n return { ...(address === undefined ? {} : { address }), port, dshPort, configureFirewall }\n}\n\nfunction dshHome(): string {\n return resolve(process.env.DSH_HOME ?? join(homedir(), '.dsh'))\n}\n\nasync function runElevatedPowerShell(script: string): Promise<void> {\n const encoded = Buffer.from(script, 'utf16le').toString('base64')\n const launch = [\n \"$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru\",\n ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${encoded}')`,\n '; exit $process.ExitCode',\n ].join(' ')\n await execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', launch], { windowsHide: true })\n}\n\nasync function configureWindowsFirewall(port: number): Promise<void> {\n if (process.platform !== 'win32') return\n const script = [\n \"$ErrorActionPreference = 'Stop'\",\n `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n `New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,\n `New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,\n ].join('; ')\n console.log('Windows will request administrator approval for two LAN-only firewall rules.')\n await runElevatedPowerShell(script)\n}\n\nasync function removeWindowsFirewall(): Promise<void> {\n if (process.platform !== 'win32') return\n await runElevatedPowerShell([\n \"$ErrorActionPreference = 'Stop'\",\n `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n ].join('; '))\n}\n\nasync function setup(args: readonly string[]): Promise<void> {\n const options = parseOptions(args)\n const preferredInterfaces = options.address === undefined ? await preferredLanInterfaceNames() : []\n const network = selectLanNetwork(options.address, undefined, undefined, preferredInterfaces)\n const home = dshHome()\n const directory = join(home, 'mobile-access')\n const tls = join(directory, 'tls')\n await mkdir(tls, { recursive: true, mode: 0o700 })\n\n const legacyCertFile = join(tls, 'cert.pem')\n const legacyKeyFile = join(tls, 'key.pem')\n const certFile = join(tls, 'server-cert.pem')\n const keyFile = join(tls, 'server-key.pem')\n const caCertFile = join(tls, 'ca.pem')\n const caKeyFile = join(tls, 'ca-key.pem')\n const androidCertificate = join(tls, 'dsh-mobile-ca.cer')\n const managedTls: ManagedSetup['tls'] = {\n mode: 'managed',\n caCertFile,\n caKeyFile,\n certFile,\n keyFile,\n }\n const ca = await ensureManagedCa(managedTls, { certFile: legacyCertFile, keyFile: legacyKeyFile })\n const managedSetup: ManagedSetup = {\n version: 2,\n networkInterface: network.name,\n listenPort: options.port,\n upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,\n tls: managedTls,\n }\n await refreshManagedServerCertificate(managedSetup, network.address)\n await writeFile(androidCertificate, ca.raw, { mode: 0o600 })\n await Promise.all([\n restrictPrivateFile(caCertFile),\n restrictPrivateFile(caKeyFile),\n restrictPrivateFile(certFile),\n restrictPrivateFile(keyFile),\n restrictPrivateFile(androidCertificate),\n ])\n if (options.configureFirewall) await configureWindowsFirewall(options.port)\n\n const customCss = join(directory, 'mobile.css')\n try {\n await readFile(customCss)\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n await writeFile(customCss, [\n '/* Safe mobile overrides. DSH Mobile applies saved changes on the phone automatically. */',\n ':root {',\n ' --dsh-mobile-accent: #2563eb;',\n ' --dsh-mobile-font-scale: 1;',\n ' --dsh-mobile-radius: 14px;',\n '}',\n '',\n ].join('\\n'), { mode: 0o600 })\n }\n\n const customScript = join(directory, 'mobile.js')\n try {\n await readFile(customScript)\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n await writeFile(customScript, [\n '/* Mount mobile-only Web features here. Saved changes are applied automatically. */',\n 'window.dshMobile.register(({ root }) => {',\n ' root.replaceChildren()',\n ' return () => root.replaceChildren()',\n '})',\n '',\n ].join('\\n'), { mode: 0o600 })\n }\n\n const extensions = join(directory, 'extensions')\n await mkdir(extensions, { recursive: true, mode: 0o700 })\n await createExtensionScaffold(extensions, 'custom', '自定义移动扩展', false)\n\n const origin = `https://${network.address}:${String(options.port)}`\n await Promise.all([\n writeFile(join(directory, 'setup.json'), `${JSON.stringify({\n ...managedSetup,\n tls: Object.fromEntries(Object.entries(managedSetup.tls)\n .map(([key, value]) => [key, typeof value === 'string' ? value.replaceAll('\\\\', '/') : value])),\n }, null, 2)}\\n`, { mode: 0o600 }),\n writeFile(join(directory, 'control.json'), '{\"version\":1,\"enabled\":true}\\n', { mode: 0o600 }),\n ])\n await Promise.all([\n restrictPrivateFile(join(directory, 'setup.json')),\n restrictPrivateFile(join(directory, 'control.json')),\n ])\n\n console.log(`DSH Mobile follows ${network.name} and is currently configured for ${origin}`)\n console.log(`Install this CA certificate on Android once: ${androidCertificate}`)\n console.log(`Ask DSH to customize the mobile Web UI and features in: ${customCss} and ${customScript}`)\n console.log(`Additional extensions live in: ${extensions}`)\n console.log('Start DSH with: dsh --profile web')\n console.log('Then open the Mobile card in the lower-left corner and create a pairing key.')\n}\n\nasync function createExtensionScaffold(root: string, id: string, name: string, refuseExisting = true): Promise<void> {\n assertExtensionId(id)\n await mkdir(root, { recursive: true, mode: 0o700 })\n const directory = join(root, id)\n try {\n await mkdir(directory, { recursive: false, mode: 0o700 })\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'EEXIST' && !refuseExisting) return\n if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error(`extension directory already exists: ${id}`)\n throw error\n }\n const files: Readonly<Record<string, string>> = {\n 'extension.json': `${JSON.stringify({ schemaVersion: 1, id, name, version: '0.1.0', description: '在手机端扩展 DSH' }, null, 2)}\\n`,\n 'host.mjs': `export default async function activate(api) {\\n api.action('hello', {\\n input: api.schema.object({ name: api.schema.string().max(80) }),\\n async run({ signal, deviceId }, input) {\\n void signal; void deviceId\\n return { message: \\`Hello, \\${input.name}\\` }\\n },\\n })\\n}\\n`,\n 'mobile.js': `window.dshMobile?.define?.({\\n apiVersion: 1,\\n id: '${id}',\\n activate(api) {\\n return api.ui.registerSurface({\\n id: '${id}-page', placement: 'page', label: ${JSON.stringify(name)},\\n mount(container) {\\n container.textContent = ${JSON.stringify(`这是 ${name} 的移动页面。`)}\\n return () => container.replaceChildren()\\n },\\n })\\n },\\n})\\n`,\n 'mobile.css': `/* ${name.replaceAll('*/', '* /')} 的移动端样式。保存后通常会在几秒内刷新。 */\\n`,\n }\n try {\n for (const [file, contents] of Object.entries(files)) await writeFile(join(directory, file), contents, { encoding: 'utf8', flag: 'wx', mode: 0o600 })\n } catch (error) {\n await rm(directory, { recursive: true, force: true })\n throw error\n }\n console.log(`Created extension: ${directory}`)\n}\n\nasync function extensionCommand(args: readonly string[]): Promise<void> {\n const [subcommand, id, ...rest] = args\n if (subcommand !== 'create' || id === undefined) throw new Error('usage: extension create <id> [--name <name>]')\n let name = id\n for (let index = 0; index < rest.length; index += 1) {\n if (rest[index] === '--name' && rest[index + 1] !== undefined) { name = rest[index + 1]!; index += 1; continue }\n throw new Error(`unknown extension option: ${rest[index] ?? ''}`)\n }\n if (name.length === 0 || name.length > 120 || /[\\u0000-\\u001f\\u007f]/u.test(name)) throw new Error('--name is invalid')\n await createExtensionScaffold(join(dshHome(), 'mobile-access', 'extensions'), id, name)\n}\n\nasync function purge(args: readonly string[]): Promise<void> {\n if (args.length !== 1 || args[0] !== '--yes') throw new Error('purge requires --yes')\n const home = dshHome()\n await rm(join(home, 'mobile-access'), { recursive: true, force: true })\n await removeWindowsFirewall()\n console.log('Removed DSH Mobile certificates, devices, preferences, and custom Web files.')\n}\n\nfunction help(): void {\n console.log([\n 'dsh-mobile setup [--address 192.168.x.x] [--port 3443] [--dsh-port 3080] [--no-firewall]',\n 'dsh-mobile extension create <id> [--name <name>]',\n 'dsh-mobile purge --yes',\n '',\n 'Run through the DSH profile:',\n ' dsh plugin --profile web exec dsh-mobile setup',\n ].join('\\n'))\n}\n\nasync function main(): Promise<void> {\n const [command = 'help', ...args] = process.argv.slice(2)\n if (command === 'setup') await setup(args)\n else if (command === 'extension') await extensionCommand(args)\n else if (command === 'purge') await purge(args)\n else if (command === 'help' || command === '--help' || command === '-h') help()\n else throw new Error(`unknown command: ${command}`)\n}\n\nmain().catch((error: unknown) => {\n console.error(error instanceof Error ? error.message : String(error))\n process.exitCode = 1\n})\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"cli.js","names":["execFile","execFile","execFile"],"sources":["../src/exec-file.ts","../src/private-file.ts","../src/managed-setup.ts","../src/extensions.ts","../src/cli.ts"],"sourcesContent":["import { execFile, type ExecFileOptions } from 'node:child_process'\n\n/** Capture both output streams even when a desktop host wraps execFile without Node's promisify metadata. */\nexport function execFileText(\n file: string,\n args: readonly string[],\n options: Omit<ExecFileOptions, 'encoding'> & { encoding?: 'utf8' } = {},\n): Promise<{ stdout: string; stderr: string }> {\n return new Promise((resolve, reject) => {\n execFile(file, [...args], { windowsHide: true, ...options, encoding: 'utf8' }, (error, stdout, stderr) => {\n if (error !== null) { reject(error); return }\n if (typeof stdout !== 'string' || typeof stderr !== 'string') {\n reject(new Error('subprocess returned invalid text output'))\n return\n }\n resolve({ stdout, stderr })\n })\n })\n}\n","import { chmod } from 'node:fs/promises'\nimport { execFileText as execFile } from './exec-file.js'\n\nlet userSidTask: Promise<string> | undefined\n\nasync function currentWindowsUserSid(): Promise<string> {\n userSidTask ??= execFile('whoami.exe', ['/user', '/fo', 'csv', '/nh'], {\n encoding: 'utf8',\n windowsHide: true,\n timeout: 10_000,\n }).then(({ stdout }) => {\n const match = /,\"(S-\\d(?:-\\d+)+)\"\\s*$/u.exec(stdout.trim())\n if (match?.[1] === undefined) throw new Error('unable to resolve the current Windows user SID')\n return match[1]\n }).catch((error: unknown) => {\n userSidTask = undefined\n throw error\n })\n return userSidTask\n}\n\n/** Restrict a sensitive regular file to the current user and Windows administrators. */\nexport async function restrictPrivateFile(file: string, mode = 0o600): Promise<void> {\n await chmod(file, mode)\n if (process.platform !== 'win32') return\n const userSid = await currentWindowsUserSid()\n await execFile('icacls.exe', [\n file,\n '/inheritance:r',\n '/grant:r',\n `*${userSid}:(F)`,\n '*S-1-5-18:(F)',\n '*S-1-5-32-544:(F)',\n '/remove:g',\n '*S-1-1-0',\n '*S-1-5-11',\n '*S-1-5-32-545',\n ], { encoding: 'utf8', windowsHide: true, timeout: 10_000 })\n}\n","import {\n X509Certificate,\n createPrivateKey,\n createPublicKey,\n} from 'node:crypto'\nimport { execFileText as execFile } from './exec-file.js'\nimport { mkdir, readFile, rename, writeFile } from 'node:fs/promises'\nimport { networkInterfaces, type NetworkInterfaceInfo } from 'node:os'\nimport { basename, dirname, join } from 'node:path'\nimport { generate } from 'selfsigned'\nimport { restrictPrivateFile } from './private-file.js'\n\n/** One active private IPv4 address tied to a stable operating-system interface name. */\nexport interface LanNetwork {\n readonly name: string\n readonly address: string\n readonly cidr: string\n}\n\n/** Versioned setup that survives DHCP address changes on the selected interface. */\nexport interface ManagedSetup {\n readonly version: 2\n readonly networkInterface: string\n readonly listenPort: number\n /** Legacy setup snapshot retained for file compatibility; the active WebServer port wins at runtime. */\n readonly upstreamOrigin: string\n readonly tls: {\n readonly mode: 'managed'\n readonly caCertFile: string\n readonly caKeyFile: string\n readonly certFile: string\n readonly keyFile: string\n }\n}\n\ntype InterfaceTable = NodeJS.Dict<NetworkInterfaceInfo[]>\ntype RouteCommand = (file: string, args: readonly string[]) => Promise<string>\n\nconst VIRTUAL_INTERFACE_MARKERS = [\n 'bridge', 'docker', 'hyper-v', 'mihomo', 'radmin', 'tailscale', 'tap', 'tun',\n 'utun', 'vbox', 'veth', 'virtual', 'vmware', 'vpn', 'vethernet', 'wsl', 'zerotier',\n]\n\nfunction requiredString(value: unknown, name: string): string {\n if (typeof value !== 'string' || value.length === 0) throw new Error(`${name} must be a non-empty string`)\n return value\n}\n\n/** Validate the durable managed setup before it controls network and filesystem operations. */\nexport function parseManagedSetup(value: unknown): ManagedSetup {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) {\n throw new Error('mobile setup file must be an object')\n }\n const record = value as Record<string, unknown>\n if (record.version !== 2 || Reflect.ownKeys(record)\n .some(key => typeof key !== 'string' || !['version', 'networkInterface', 'listenPort', 'upstreamOrigin', 'tls'].includes(key))) {\n throw new Error('mobile setup file has an unsupported format')\n }\n if (!Number.isSafeInteger(record.listenPort) || (record.listenPort as number) < 1024\n || (record.listenPort as number) > 65535) {\n throw new Error('mobile setup listenPort must be from 1024 through 65535')\n }\n if (typeof record.tls !== 'object' || record.tls === null || Array.isArray(record.tls)) {\n throw new Error('mobile setup tls must be an object')\n }\n const tls = record.tls as Record<string, unknown>\n if (tls.mode !== 'managed' || Reflect.ownKeys(tls)\n .some(key => typeof key !== 'string' || !['mode', 'caCertFile', 'caKeyFile', 'certFile', 'keyFile'].includes(key))) {\n throw new Error('mobile setup tls has an unsupported format')\n }\n return Object.freeze({\n version: 2,\n networkInterface: requiredString(record.networkInterface, 'mobile setup networkInterface'),\n listenPort: record.listenPort as number,\n upstreamOrigin: requiredString(record.upstreamOrigin, 'mobile setup upstreamOrigin'),\n tls: Object.freeze({\n mode: 'managed',\n caCertFile: requiredString(tls.caCertFile, 'mobile setup tls.caCertFile'),\n caKeyFile: requiredString(tls.caKeyFile, 'mobile setup tls.caKeyFile'),\n certFile: requiredString(tls.certFile, 'mobile setup tls.certFile'),\n keyFile: requiredString(tls.keyFile, 'mobile setup tls.keyFile'),\n }),\n })\n}\n\nfunction privateIpv4(value: string): boolean {\n const parts = value.split('.').map(Number)\n return parts.length === 4 && parts.every(part => Number.isInteger(part) && part >= 0 && part <= 255)\n && (parts[0] === 10\n || (parts[0] === 172 && parts[1]! >= 16 && parts[1]! <= 31)\n || (parts[0] === 192 && parts[1] === 168))\n}\n\nfunction networkCidr(address: string, cidr: string): string {\n const prefix = Number(cidr.slice(cidr.lastIndexOf('/') + 1))\n const value = address.split('.').reduce((total, part) => ((total << 8) | Number(part)) >>> 0, 0)\n const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0\n const network = (value & mask) >>> 0\n return `${[24, 16, 8, 0].map(shift => (network >>> shift) & 255).join('.')}/${String(prefix)}`\n}\n\n/** List current private IPv4 candidates with their interface identity. */\nexport function availableLanNetworks(table: InterfaceTable = networkInterfaces()): LanNetwork[] {\n const candidates = Object.entries(table).flatMap(([name, entries]) => (entries ?? [])\n .filter(entry => entry.family === 'IPv4' && !entry.internal && privateIpv4(entry.address) && entry.cidr !== null)\n .map(entry => ({ name, address: entry.address, cidr: networkCidr(entry.address, entry.cidr!) })))\n return [...new Map(candidates.map(entry => [`${entry.name}\\0${entry.address}`, entry])).values()]\n}\n\nfunction likelyVirtualInterface(name: string): boolean {\n const normalized = name.toLowerCase().replaceAll(/[^a-z0-9]+/gu, ' ')\n return VIRTUAL_INTERFACE_MARKERS.some(marker => normalized.includes(marker.replaceAll('-', ' ')))\n || /^(?:br|wg)\\d*\\b/u.test(normalized)\n}\n\nasync function runRouteCommand(file: string, args: readonly string[]): Promise<string> {\n const result = await execFile(file, [...args], { encoding: 'utf8', windowsHide: true })\n return result.stdout\n}\n\nfunction uniqueLines(output: string): string[] {\n return [...new Set(output.split(/\\r?\\n/gu).map(line => line.trim()).filter(Boolean))]\n}\n\n/** Return operating-system default-route interfaces in routing preference order. */\nexport async function preferredLanInterfaceNames(\n platform: NodeJS.Platform = process.platform,\n run: RouteCommand = runRouteCommand,\n): Promise<string[]> {\n try {\n if (platform === 'win32') {\n const script = [\n \"$routes = Get-NetRoute -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop\",\n \"$ranked = $routes | Where-Object { $_.State -eq 'Alive' -and $_.NextHop -ne '0.0.0.0' } | ForEach-Object {\",\n ' $route = $_',\n ' $adapter = Get-NetAdapter -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue',\n ' $ip = Get-NetIPInterface -AddressFamily IPv4 -InterfaceIndex $route.InterfaceIndex -ErrorAction SilentlyContinue',\n \" if ($adapter -and $ip -and $adapter.Status -eq 'Up' -and $adapter.HardwareInterface -eq $true -and $adapter.Virtual -ne $true) {\",\n ' [pscustomobject]@{ Name = $route.InterfaceAlias; Metric = [int]$route.RouteMetric + [int]$ip.InterfaceMetric }',\n ' }',\n '}',\n '$ranked | Sort-Object Metric | Select-Object -ExpandProperty Name -Unique',\n ].join('; ')\n return uniqueLines(await run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]))\n }\n if (platform === 'linux') {\n const routes = uniqueLines(await run('ip', ['-o', '-4', 'route', 'show', 'default']))\n .map(line => ({\n name: /(?:^|\\s)dev\\s+(\\S+)/u.exec(line)?.[1],\n metric: Number(/(?:^|\\s)metric\\s+(\\d+)/u.exec(line)?.[1] ?? 0),\n }))\n .filter((route): route is { name: string; metric: number } => route.name !== undefined\n && !likelyVirtualInterface(route.name))\n .sort((left, right) => left.metric - right.metric)\n return [...new Set(routes.map(route => route.name))]\n }\n if (platform === 'darwin') {\n const name = /^\\s*interface:\\s*(\\S+)\\s*$/mu.exec(await run('route', ['-n', 'get', 'default']))?.[1]\n return name === undefined || likelyVirtualInterface(name) ? [] : [name]\n }\n } catch {\n // Route discovery is advisory; deterministic candidate checks below remain the fallback.\n }\n return []\n}\n\n/** Select an active LAN, optionally by address or by a previously saved interface name. */\nexport function selectLanNetwork(\n requestedAddress?: string,\n requestedInterface?: string,\n table?: InterfaceTable,\n preferredInterfaces: readonly string[] = [],\n): LanNetwork {\n const candidates = availableLanNetworks(table)\n if (requestedAddress !== undefined) {\n const match = candidates.find(candidate => candidate.address === requestedAddress)\n if (match === undefined) throw new Error(`--address ${requestedAddress} is not an active private LAN address`)\n return match\n }\n if (requestedInterface !== undefined) {\n const matches = candidates.filter(candidate => candidate.name === requestedInterface)\n if (matches.length === 1) return matches[0]!\n if (matches.length === 0) {\n throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} is not connected`)\n }\n throw new Error(`saved LAN interface ${JSON.stringify(requestedInterface)} has more than one private IPv4 address`)\n }\n if (candidates.length === 1) return candidates[0]!\n if (candidates.length === 0) throw new Error('no active private LAN address was found; connect to Wi-Fi or Ethernet')\n for (const name of preferredInterfaces) {\n const matches = candidates.filter(candidate => candidate.name === name)\n if (matches.length === 1) return matches[0]!\n }\n const physicalCandidates = candidates.filter(candidate => !likelyVirtualInterface(candidate.name))\n if (physicalCandidates.length === 1) return physicalCandidates[0]!\n throw new Error(`more than one LAN address is active; rerun with --address and one of: ${candidates.map(entry => `${entry.name}=${entry.address}`).join(', ')}`)\n}\n\nfunction assertMatchingCa(certPem: string, keyPem: string): X509Certificate {\n const certificate = new X509Certificate(certPem)\n if (!certificate.ca || certificate.subject !== certificate.issuer\n || !certificate.verify(certificate.publicKey)) {\n throw new Error('managed TLS CA must be a self-signed CA certificate')\n }\n const privatePublic = createPublicKey(createPrivateKey(keyPem)).export({ format: 'der', type: 'spki' })\n const certificatePublic = certificate.publicKey.export({ format: 'der', type: 'spki' })\n if (!privatePublic.equals(certificatePublic)) throw new Error('managed TLS CA certificate and key do not match')\n if (Date.parse(certificate.validFrom) > Date.now() || Date.parse(certificate.validTo) <= Date.now()) {\n throw new Error('managed TLS CA certificate is not currently valid')\n }\n return certificate\n}\n\nasync function atomicWrite(file: string, contents: string | Uint8Array): Promise<void> {\n const directory = dirname(file)\n await mkdir(directory, { recursive: true, mode: 0o700 })\n const temporary = join(directory, `.${basename(file)}.${process.pid}.tmp`)\n await writeFile(temporary, contents, { mode: 0o600 })\n await rename(temporary, file)\n await restrictPrivateFile(file)\n}\n\n/** Create a long-lived CA or migrate the legacy self-signed server certificate as that CA. */\nexport async function ensureManagedCa(\n setup: ManagedSetup['tls'],\n legacy?: { readonly certFile: string; readonly keyFile: string },\n): Promise<X509Certificate> {\n let certPem: string | undefined\n let keyPem: string | undefined\n try {\n [certPem, keyPem] = await Promise.all([readFile(setup.caCertFile, 'utf8'), readFile(setup.caKeyFile, 'utf8')])\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n let migrated = false\n if (legacy !== undefined) {\n try {\n [certPem, keyPem] = await Promise.all([readFile(legacy.certFile, 'utf8'), readFile(legacy.keyFile, 'utf8')])\n assertMatchingCa(certPem, keyPem)\n migrated = true\n } catch (legacyError) {\n if ((legacyError as NodeJS.ErrnoException).code !== 'ENOENT') throw legacyError\n }\n }\n if (!migrated) {\n const now = new Date()\n const notAfter = new Date(now)\n notAfter.setFullYear(notAfter.getFullYear() + 5)\n const generated = await generate([{ name: 'commonName', value: 'DeepSeek Harness Mobile CA' }], {\n keyType: 'ec',\n curve: 'P-256',\n algorithm: 'sha256',\n notBeforeDate: new Date(now.getTime() - 5 * 60_000),\n notAfterDate: notAfter,\n extensions: [\n { name: 'basicConstraints', cA: true, critical: true },\n { name: 'keyUsage', digitalSignature: true, keyCertSign: true, cRLSign: true, critical: true },\n ],\n })\n certPem = generated.cert\n keyPem = generated.private\n }\n if (certPem === undefined || keyPem === undefined) throw new Error('managed TLS CA creation did not produce key material')\n await Promise.all([atomicWrite(setup.caCertFile, certPem), atomicWrite(setup.caKeyFile, keyPem)])\n }\n if (certPem === undefined || keyPem === undefined) throw new Error('managed TLS CA creation did not produce key material')\n await Promise.all([restrictPrivateFile(setup.caCertFile), restrictPrivateFile(setup.caKeyFile)])\n return assertMatchingCa(certPem, keyPem)\n}\n\n/** Sign and atomically install a server leaf for the interface's current address. */\nexport async function refreshManagedServerCertificate(setup: ManagedSetup, address: string): Promise<void> {\n await Promise.all([restrictPrivateFile(setup.tls.caCertFile), restrictPrivateFile(setup.tls.caKeyFile)])\n const [caCert, caKey] = await Promise.all([\n readFile(setup.tls.caCertFile, 'utf8'),\n readFile(setup.tls.caKeyFile, 'utf8'),\n ])\n assertMatchingCa(caCert, caKey)\n const now = new Date()\n const notAfter = new Date(now)\n notAfter.setDate(notAfter.getDate() + 397)\n const server = await generate([{ name: 'commonName', value: 'DeepSeek Harness Mobile' }], {\n keyType: 'ec',\n curve: 'P-256',\n algorithm: 'sha256',\n notBeforeDate: new Date(now.getTime() - 5 * 60_000),\n notAfterDate: notAfter,\n ca: { cert: caCert, key: caKey },\n extensions: [\n { name: 'basicConstraints', cA: false, critical: true },\n { name: 'keyUsage', digitalSignature: true, critical: true },\n { name: 'extKeyUsage', serverAuth: true },\n { name: 'subjectAltName', altNames: [{ type: 7, ip: address }] },\n ],\n })\n await Promise.all([\n atomicWrite(setup.tls.certFile, server.cert),\n atomicWrite(setup.tls.keyFile, server.private),\n ])\n}\n\n/** Resolve the saved interface to the ordinary gateway config consumed by the Host plugin. */\nexport async function materializeManagedSetup(\n setup: ManagedSetup,\n table?: InterfaceTable,\n): Promise<Record<string, unknown>> {\n const network = selectLanNetwork(undefined, setup.networkInterface, table)\n await refreshManagedServerCertificate(setup, network.address)\n const ca = new X509Certificate(await readFile(setup.tls.caCertFile, 'utf8'))\n return {\n publicOrigin: `https://${network.address}:${String(setup.listenPort)}`,\n listenHost: network.address,\n allowedCidrs: [network.cidr],\n instanceId: ca.fingerprint256.replaceAll(':', '').toLowerCase(),\n pairingCaFile: setup.tls.caCertFile,\n tls: {\n mode: 'provided',\n certFile: setup.tls.certFile,\n keyFile: setup.tls.keyFile,\n },\n }\n}\n","import { createHash } from 'node:crypto'\nimport type { Dirent } from 'node:fs'\nimport { lstat, mkdir, opendir, readFile, realpath } from 'node:fs/promises'\nimport { basename, isAbsolute, join, relative, resolve } from 'node:path'\nimport { pathToFileURL } from 'node:url'\nimport { Service, type Context } from '@deepseek-ai/cordis'\nimport z from '@deepseek-ai/schemastery'\nimport { finished, type Readable } from 'node:stream'\n\n/** Maximum sizes enforced at the local-extension filesystem boundary. */\nexport const EXTENSION_LIMITS = Object.freeze({\n manifest: 64 * 1024,\n script: 1024 * 1024,\n css: 512 * 1024,\n asset: 8 * 1024 * 1024,\n assetFiles: 256,\n assetBytes: 32 * 1024 * 1024,\n assetDepth: 8,\n})\n\n/** A misbehaving host activation must not wedge the local watcher forever. */\nconst HOST_ACTIVATION_TIMEOUT_MS = 5_000\n\n/** The previous Host outlives the hidden-page refresh interval and one timed refresh. */\nconst RETIRED_GENERATION_TTL_MS = 10 * 60_000\n\n/** Extension teardown is advisory and must never stop watcher progress. */\nconst HOST_TEARDOWN_TIMEOUT_MS = 2_000\n\nasync function withActivationTimeout<T>(promise: Promise<T>, id: string, signal: AbortSignal): Promise<T> {\n let timer: NodeJS.Timeout | undefined\n let onAbort: (() => void) | undefined\n try {\n return await Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => reject(new MobileExtensionError('host_load_timeout', `extension ${id} activation timed out`, 500)), HOST_ACTIVATION_TIMEOUT_MS)\n }),\n new Promise<never>((_, reject) => {\n const abort = (): void => { reject(new MobileExtensionError('host_activation_closed', `extension ${id} activation is closed`, 409)) }\n if (signal.aborted) abort()\n else { onAbort = abort; signal.addEventListener('abort', abort, { once: true }) }\n }),\n ])\n } finally {\n if (timer !== undefined) clearTimeout(timer)\n if (onAbort !== undefined) signal.removeEventListener('abort', onAbort)\n }\n}\n\n/** A controlled business failure returned by an extension action or route. */\nexport class MobileExtensionError extends Error {\n constructor(readonly code: string, message: string, readonly status = 400) {\n super(message)\n this.name = 'MobileExtensionError'\n }\n}\n\n/** One host-side action exposed by an extension. */\nexport interface MobileHostAction {\n readonly input?: { parse(value: unknown): unknown }\n readonly run: (context: MobileActionContext, input: unknown) => unknown | Promise<unknown>\n}\n\n/** Context supplied to a host action. */\nexport interface MobileActionContext {\n readonly signal: AbortSignal\n readonly deviceId: string\n}\n\n/** Safe request values supplied to a host route. */\nexport interface MobileRouteRequest {\n readonly method: string\n readonly pathname: string\n readonly query: Readonly<URLSearchParams>\n readonly headers: Readonly<Record<string, string>>\n readonly body: Uint8Array\n readonly signal: AbortSignal\n readonly deviceId: string\n}\n\n/** Values an extension route may return; status is a final HTTP code from 200 through 599. */\nexport interface MobileRouteResponse {\n readonly status?: number\n readonly contentType?: string\n readonly headers?: Readonly<Record<string, string>>\n readonly body: string | Uint8Array | Readable\n}\n\n/** One host-side route exposed by an extension. */\nexport interface MobileHostRoute {\n readonly method: string\n readonly path: string\n readonly kind?: 'exact' | 'prefix'\n readonly handle: (request: MobileRouteRequest) => MobileRouteResponse | Promise<MobileRouteResponse>\n}\n\n/** Metadata shared by local and npm-provided extensions. */\nexport interface MobileExtensionManifest {\n readonly schemaVersion: 1\n readonly id: string\n readonly name: string\n readonly version: string\n readonly description?: string\n}\n\n/** Definition registered by a normal Cordis plugin. */\nexport interface MobileExtensionDefinition extends MobileExtensionManifest {\n readonly actions?: Readonly<Record<string, MobileHostAction>>\n readonly routes?: readonly MobileHostRoute[]\n}\n\ndeclare module '@deepseek-ai/cordis' {\n interface Context {\n mobileAccess: MobileAccessService\n }\n}\n\n/** A local extension manifest read from extension.json. */\nexport interface LocalExtensionManifest extends MobileExtensionManifest {}\n\n/** Public snapshot sent to the mobile browser. */\nexport interface MobileExtensionClientEntry extends MobileExtensionManifest {\n readonly generation?: string\n readonly scriptUrl?: string\n readonly styleUrl?: string\n readonly assetsUrl?: string\n}\n\ninterface LocalAssetSnapshot {\n readonly body: Buffer\n readonly digest: string\n readonly name: string\n}\n\n/** Small status summary used by the desktop mobile-access card. */\nexport interface MobileExtensionStatus {\n readonly loaded: number\n readonly failed: number\n}\n\ninterface ActiveLocalExtension {\n readonly manifest: LocalExtensionManifest\n readonly directory: string\n readonly scriptBody?: Buffer\n readonly styleBody?: Buffer\n readonly assets: ReadonlyMap<string, LocalAssetSnapshot>\n readonly host: MobileExtensionDefinition\n readonly controller: AbortController\n readonly cleanups: readonly (() => void | Promise<void>)[]\n readonly digest: string\n}\n\ninterface RegisteredExtension {\n readonly definition: MobileExtensionDefinition\n readonly dispose: () => void\n}\n\ninterface RetiredLocalExtension {\n readonly active: ActiveLocalExtension\n readonly timer: NodeJS.Timeout\n}\n\ntype HostApi = {\n readonly manifest: LocalExtensionManifest\n readonly context: Context\n readonly schema: typeof z\n readonly signal: AbortSignal\n action(name: string, spec: MobileHostAction): void\n route(spec: MobileHostRoute): void\n effect(setup: () => void | (() => void | Promise<void>) | Promise<void | (() => void | Promise<void>)>): void\n}\n\ntype LocalHostModule = { readonly default?: (api: HostApi) => void | Promise<void> }\n\n/** Validate user-facing extension text without allowing control characters. */\nfunction text(value: unknown, field: string, maximum: number, required: boolean): string | undefined {\n if (value === undefined && !required) return undefined\n if (typeof value !== 'string' || (required && value.length === 0) || value.length > maximum\n || /[\\u0000-\\u001f\\u007f]/u.test(value)) throw new MobileExtensionError('invalid_manifest', `${field} is invalid`)\n return value\n}\n\n/** Validate a stable extension id. */\nexport function assertExtensionId(value: unknown): string {\n if (typeof value !== 'string' || !/^[a-z][a-z0-9-]{0,63}$/u.test(value)) {\n throw new MobileExtensionError('invalid_manifest', 'extension id is invalid')\n }\n return value\n}\n\n/** Validate a manifest from JSON or a plugin definition. */\nexport function parseExtensionManifest(value: unknown): LocalExtensionManifest {\n if (value === null || typeof value !== 'object' || Array.isArray(value)) {\n throw new MobileExtensionError('invalid_manifest', 'extension.json must be an object')\n }\n const record = value as Record<string, unknown>\n if (record.schemaVersion !== 1) throw new MobileExtensionError('invalid_manifest', 'unsupported extension schema')\n const id = assertExtensionId(record.id)\n const name = text(record.name, 'name', 120, true) as string\n const version = text(record.version, 'version', 64, true) as string\n const description = text(record.description, 'description', 500, false)\n for (const key of Reflect.ownKeys(record)) {\n if (!['schemaVersion', 'id', 'name', 'version', 'description'].includes(String(key))) {\n throw new MobileExtensionError('invalid_manifest', 'extension.json has unknown fields')\n }\n }\n return Object.freeze({ schemaVersion: 1, id, name, version, ...(description === undefined ? {} : { description }) })\n}\n\nfunction normalizeRelativePath(value: string, field: string): string {\n if (value.length === 0 || value.includes('\\0') || isAbsolute(value)) throw new MobileExtensionError('invalid_extension_path', `${field} is invalid`)\n const normalized = value.replaceAll('\\\\', '/')\n if (normalized.split('/').some(part => part === '' || part === '.' || part === '..')) {\n throw new MobileExtensionError('invalid_extension_path', `${field} escapes extension directory`)\n }\n return normalized\n}\n\nasync function regularFile(path: string, maximum: number, field: string): Promise<{ readonly path: string; readonly size: number }> {\n let info\n try { info = await lstat(path) } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new MobileExtensionError('invalid_extension', `${field} is missing`)\n throw error\n }\n if (!info.isFile() || info.isSymbolicLink() || info.size > maximum) {\n throw new MobileExtensionError('invalid_extension', `${field} must be a regular file within its size limit`)\n }\n return { path, size: info.size }\n}\n\nasync function containedPath(root: string, relativePath: string, maximum: number, field: string): Promise<{ readonly path: string; readonly size: number }> {\n const normalized = normalizeRelativePath(relativePath, field)\n const target = resolve(root, normalized)\n const rootReal = await realpath(root)\n const targetReal = await realpath(target)\n const relation = relative(rootReal, targetReal)\n if (relation === '' || relation.startsWith('..') || isAbsolute(relation)) throw new MobileExtensionError('invalid_extension_path', `${field} escapes extension directory`)\n return regularFile(targetReal, maximum, field)\n}\n\nasync function optionalFile(root: string, name: string, maximum: number, field: string): Promise<string | undefined> {\n try {\n return (await containedPath(root, name, maximum, field)).path\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined\n if (error instanceof MobileExtensionError && error.message.includes('is missing')) return undefined\n throw error\n }\n}\n\nasync function optionalBytes(root: string, name: string, maximum: number, field: string): Promise<Buffer | undefined> {\n const path = await optionalFile(root, name, maximum, field)\n return path === undefined ? undefined : readFile(path)\n}\n\nfunction assertRealPathWithin(rootReal: string, targetReal: string, field: string): void {\n const relation = relative(rootReal, targetReal)\n if (relation === '' || relation.startsWith('..') || isAbsolute(relation)) {\n throw new MobileExtensionError('invalid_extension_path', `${field} escapes extension directory`)\n }\n}\n\nasync function realExtensionRoot(directory: string): Promise<string> {\n const root = resolve(directory)\n const info = await lstat(root)\n if (!info.isDirectory() || info.isSymbolicLink()) throw new MobileExtensionError('invalid_extension', 'extension directory must be real')\n return realpath(root)\n}\n\nasync function assetSnapshot(extensionRootReal: string): Promise<ReadonlyMap<string, LocalAssetSnapshot>> {\n const assetsPath = join(extensionRootReal, 'assets')\n let assetsInfo\n try { assetsInfo = await lstat(assetsPath) } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Map()\n throw error\n }\n if (!assetsInfo.isDirectory() || assetsInfo.isSymbolicLink()) {\n throw new MobileExtensionError('invalid_extension', 'assets must be a real directory')\n }\n const assetsReal = await realpath(assetsPath)\n assertRealPathWithin(extensionRootReal, assetsReal, 'assets')\n const snapshots = new Map<string, LocalAssetSnapshot>()\n let totalBytes = 0\n const visit = async (directoryReal: string, prefix: string, depth: number): Promise<void> => {\n if (depth > EXTENSION_LIMITS.assetDepth) {\n throw new MobileExtensionError('invalid_extension', 'asset tree exceeds its depth limit')\n }\n assertRealPathWithin(extensionRootReal, directoryReal, 'asset directory')\n const handle = await opendir(directoryReal)\n const entries: Dirent[] = []\n try { for await (const entry of handle) entries.push(entry) }\n finally { await handle.close().catch(() => undefined) }\n entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)\n for (const entry of entries) {\n const path = join(directoryReal, entry.name)\n const info = await lstat(path)\n if (info.isSymbolicLink()) throw new MobileExtensionError('invalid_extension_path', 'asset escapes extension directory')\n const targetReal = await realpath(path)\n assertRealPathWithin(extensionRootReal, targetReal, 'asset')\n const key = prefix === '' ? entry.name : `${prefix}/${entry.name}`\n if (info.isDirectory()) {\n await visit(targetReal, key, depth + 1)\n continue\n }\n if (!info.isFile() || info.size > EXTENSION_LIMITS.asset) {\n throw new MobileExtensionError('invalid_extension', 'asset must be a regular file within its size limit')\n }\n const body = await readFile(targetReal)\n totalBytes += body.byteLength\n if (snapshots.size >= EXTENSION_LIMITS.assetFiles || totalBytes > EXTENSION_LIMITS.assetBytes) {\n throw new MobileExtensionError('invalid_extension', 'asset tree exceeds its aggregate limit')\n }\n snapshots.set(key, Object.freeze({ body, digest: createHash('sha256').update(body).digest('hex'), name: entry.name }))\n }\n }\n await visit(assetsReal, '', 0)\n return snapshots\n}\n\ninterface LocalExtensionFingerprint {\n readonly manifest: LocalExtensionManifest\n readonly digest: string\n readonly scriptBody?: Buffer\n readonly styleBody?: Buffer\n readonly assets: ReadonlyMap<string, LocalAssetSnapshot>\n}\n\nasync function extensionFingerprint(directory: string): Promise<LocalExtensionFingerprint> {\n const root = await realExtensionRoot(directory)\n const manifestFile = await regularFile(join(root, 'extension.json'), EXTENSION_LIMITS.manifest, 'extension.json')\n const manifestBody = await readFile(manifestFile.path)\n const manifest = parseExtensionManifest(JSON.parse(manifestBody.toString('utf8')) as unknown)\n if (manifest.id !== basename(root)) throw new MobileExtensionError('invalid_manifest', 'extension id must match its directory name')\n const [host, script, style, assets] = await Promise.all([\n optionalBytes(root, 'host.mjs', EXTENSION_LIMITS.script, 'host.mjs'),\n optionalBytes(root, 'mobile.js', EXTENSION_LIMITS.script, 'mobile.js'),\n optionalBytes(root, 'mobile.css', EXTENSION_LIMITS.css, 'mobile.css'),\n assetSnapshot(root),\n ])\n const digest = createHash('sha256').update(`manifest:${manifestBody.byteLength}:`).update(createHash('sha256').update(manifestBody).digest())\n for (const [name, body] of [['host', host], ['script', script], ['style', style]] as const) {\n digest.update(`\\0${name}:${body?.byteLength ?? -1}:`)\n if (body !== undefined) digest.update(createHash('sha256').update(body).digest())\n }\n for (const [name, asset] of assets) {\n digest.update(`\\0asset:${Buffer.byteLength(name)}:${name}:${asset.body.byteLength}:${asset.digest}`)\n }\n return {\n manifest,\n digest: digest.digest('hex'),\n assets,\n ...(script === undefined ? {} : { scriptBody: script }),\n ...(style === undefined ? {} : { styleBody: style }),\n }\n}\n\nfunction routeKey(route: MobileHostRoute): string {\n const method = route.method.toUpperCase()\n const path = normalizeRoutePath(route.path)\n return `${method} ${route.kind ?? 'exact'} ${path}`\n}\n\nfunction normalizeRoutePath(value: string): string {\n if (typeof value !== 'string' || value.length === 0 || value.length > 256\n || value.includes('?') || value.includes('#') || value.includes('\\\\') || value.includes('\\0')\n || /[\\u0000-\\u001f\\u007f]/u.test(value)) throw new MobileExtensionError('invalid_route', 'extension route path is invalid')\n const normalizedInput = value.startsWith('/') ? value : `/${value}`\n const parts = normalizedInput.split('/')\n if (parts.some(part => part === '..' || part === '.')) throw new MobileExtensionError('invalid_route', 'extension route path is invalid')\n return normalizedInput === '/' ? '/' : normalizedInput.replace(/\\/+$/u, '')\n}\n\nfunction validateDefinition(definition: MobileExtensionDefinition): MobileExtensionDefinition {\n const manifest = parseExtensionManifest({\n schemaVersion: definition.schemaVersion,\n id: definition.id,\n name: definition.name,\n version: definition.version,\n ...(definition.description === undefined ? {} : { description: definition.description }),\n })\n const actionNames = new Set<string>()\n for (const [name, action] of Object.entries(definition.actions ?? {})) {\n if (!/^[a-z][a-z0-9-]{0,63}$/u.test(name) || action === null || typeof action !== 'object' || typeof action.run !== 'function' || actionNames.has(name)) {\n throw new MobileExtensionError('invalid_action', `invalid action ${name}`)\n }\n actionNames.add(name)\n }\n const routeNames = new Set<string>()\n const routes = (definition.routes ?? []).map(route => {\n if (route === null || typeof route !== 'object' || typeof route.handle !== 'function') throw new MobileExtensionError('invalid_route', 'invalid extension route')\n const method = route.method.toUpperCase()\n if (!['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) throw new MobileExtensionError('invalid_route', 'unsupported extension route method')\n const normalized: MobileHostRoute = { ...route, method, path: normalizeRoutePath(route.path) }\n const key = routeKey(normalized)\n if (routeNames.has(key)) throw new MobileExtensionError('duplicate_route', `duplicate route ${key}`)\n routeNames.add(key)\n return normalized\n })\n return Object.freeze({ ...manifest, ...(definition.actions === undefined ? {} : { actions: Object.freeze({ ...definition.actions }) }), ...(routes.length === 0 ? {} : { routes: Object.freeze(routes) }) })\n}\n\ninterface CombinedSignalLifetime {\n readonly signal: AbortSignal\n readonly cleanup: () => void\n}\n\nfunction combineSignalLifetime(first: AbortSignal, second: AbortSignal): CombinedSignalLifetime {\n if (first.aborted || second.aborted) {\n const aborted = new AbortController()\n aborted.abort(first.aborted ? first.reason : second.reason)\n return { signal: aborted.signal, cleanup: () => undefined }\n }\n const controller = new AbortController()\n const cleanup = (): void => {\n first.removeEventListener('abort', abortFirst)\n second.removeEventListener('abort', abortSecond)\n }\n const abortFirst = (): void => { cleanup(); controller.abort(first.reason) }\n const abortSecond = (): void => { cleanup(); controller.abort(second.reason) }\n first.addEventListener('abort', abortFirst, { once: true })\n second.addEventListener('abort', abortSecond, { once: true })\n return { signal: controller.signal, cleanup }\n}\n\n/** Combine two abort lifetimes without relying on AbortSignal.any in older WebViews. */\nexport function combineSignals(first: AbortSignal, second: AbortSignal): AbortSignal {\n return combineSignalLifetime(first, second).signal\n}\n\n/** Host registry and service consumed by both npm plugins and local extensions. */\nexport class MobileAccessService extends Service {\n private readonly registered = new Map<string, RegisteredExtension>()\n private readonly local = new Map<string, ActiveLocalExtension>()\n private readonly retired = new Map<string, RetiredLocalExtension>()\n private readonly failures = new Map<string, string>()\n private readonly contentListeners = new Set<() => void>()\n private contentHash = createHash('sha256').update('').digest('hex')\n private localRoot: string | undefined\n private localContext: Context | undefined\n private localTimer: NodeJS.Timeout | undefined\n private localRefreshing: Promise<void> | undefined\n private localRefreshAbort: AbortController | undefined\n private localLifecycle = 0\n private localClosed = true\n\n constructor(ctx: Context) { super(ctx, 'mobileAccess') }\n\n /** Register a normal Cordis extension and return an idempotent disposer. */\n registerExtension(definition: MobileExtensionDefinition): () => void {\n const validated = validateDefinition(definition)\n if (this.registered.has(validated.id) || this.local.has(validated.id)) throw new Error(`mobile extension id already registered: ${validated.id}`)\n const dispose = (): void => {\n const current = this.registered.get(validated.id)\n if (current?.dispose === dispose) {\n this.registered.delete(validated.id)\n this.updateContentHash()\n }\n }\n this.registered.set(validated.id, { definition: validated, dispose })\n this.updateContentHash()\n return dispose\n }\n\n /** Aggregate digest covering every registered and active local extension. */\n contentDigest(): string {\n return this.contentHash\n }\n\n /** Subscribe to committed extension generation changes. */\n onContentChanged(listener: () => void): () => void {\n this.contentListeners.add(listener)\n return () => { this.contentListeners.delete(listener) }\n }\n\n private updateContentHash(): void {\n const parts = [\n ...[...this.registered.values()].map(entry => entry.definition.id),\n ...[...this.local.values()].map(active => `${active.manifest.id}:${active.digest}`),\n ]\n const next = createHash('sha256').update(parts.sort().join('|')).digest('hex')\n if (next === this.contentHash) return\n this.contentHash = next\n for (const listener of this.contentListeners) {\n try { listener() } catch { /* One observer cannot block a committed generation. */ }\n }\n }\n\n /** Return the current client-facing manifest, deterministically sorted by id. */\n manifest(): readonly MobileExtensionClientEntry[] {\n const entries = new Map<string, MobileExtensionClientEntry>()\n for (const { definition } of this.registered.values()) entries.set(definition.id, {\n schemaVersion: 1, id: definition.id, name: definition.name, version: definition.version,\n ...(definition.description === undefined ? {} : { description: definition.description }),\n })\n for (const active of this.local.values()) entries.set(active.manifest.id, {\n ...active.manifest,\n generation: active.digest,\n ...(active.scriptBody === undefined ? {} : { scriptUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.js?generation=${active.digest}` }),\n ...(active.styleBody === undefined ? {} : { styleUrl: `/mobile-access/extensions/${active.manifest.id}/mobile.css?generation=${active.digest}` }),\n assetsUrl: `/mobile-access/extensions/${active.manifest.id}/assets/`,\n })\n return [...entries.values()].sort((left, right) => left.id.localeCompare(right.id))\n }\n\n /** Return loaded and failed local extension counts without exposing host errors. */\n status(): MobileExtensionStatus {\n return Object.freeze({ loaded: this.registered.size + this.local.size, failed: this.failures.size })\n }\n\n /** Locate one active extension. */\n extension(id: string, generation?: string): MobileExtensionDefinition | ActiveLocalExtension | undefined {\n if (generation !== undefined) {\n const current = this.local.get(id)\n if (current?.digest === generation) return current\n const previous = this.retired.get(id)?.active\n return previous?.digest === generation ? previous : undefined\n }\n return this.local.get(id) ?? this.registered.get(id)?.definition\n }\n\n /** Return the active local generation signal for gateway cancellation wiring. */\n signal(id: string, generation?: string): AbortSignal | undefined {\n const extension = this.extension(id, generation)\n return extension !== undefined && 'host' in extension ? extension.controller.signal : undefined\n }\n\n /** Read a local client entry after validating that it remains inside its directory. */\n async readClientFile(id: string, kind: 'script' | 'style', signal?: AbortSignal, generation?: string): Promise<{ readonly body: Buffer; readonly digest: string }> {\n signal?.throwIfAborted()\n const selected = this.extension(id, generation)\n const active = selected !== undefined && 'host' in selected ? selected : undefined\n if (active === undefined) throw new MobileExtensionError('extension_generation_not_found', 'extension generation not found', 404)\n const snapshot = kind === 'script' ? active.scriptBody : active.styleBody\n if (snapshot === undefined) throw new MobileExtensionError('extension_asset_not_found', 'extension asset not found', 404)\n const body = Buffer.from(snapshot)\n return { body, digest: createHash('sha256').update(body).digest('hex') }\n }\n\n /** Read a generation-pinned static asset from its validated snapshot. */\n async readAsset(id: string, assetPath: string, signal?: AbortSignal, generation?: string): Promise<{ readonly body: Buffer; readonly digest: string; readonly name: string }> {\n signal?.throwIfAborted()\n const selected = this.extension(id, generation)\n const active = selected !== undefined && 'host' in selected ? selected : undefined\n if (active === undefined) throw new MobileExtensionError('extension_generation_not_found', 'extension generation not found', 404)\n const normalized = normalizeRelativePath(assetPath, 'asset')\n const asset = active.assets.get(normalized)\n if (asset === undefined) throw new MobileExtensionError('extension_asset_not_found', 'extension asset not found', 404)\n return { body: Buffer.from(asset.body), digest: asset.digest, name: asset.name }\n }\n\n /** Invoke one action after parsing its input and binding the request lifetime. */\n async invoke(id: string, actionName: string, input: unknown, context: MobileActionContext, generation?: string): Promise<unknown> {\n const extension = this.extension(id, generation)\n if (extension === undefined) throw new MobileExtensionError('extension_not_found', 'extension not found', 404)\n const definition = 'host' in extension ? extension.host : extension\n const action = definition.actions?.[actionName]\n if (action === undefined) throw new MobileExtensionError('action_not_found', 'action not found', 404)\n let parsed = input\n try { parsed = action.input?.parse(input) ?? input } catch { throw new MobileExtensionError('invalid_action_input', 'action input is invalid', 400) }\n const lifetime = 'host' in extension ? combineSignalLifetime(extension.controller.signal, context.signal) : undefined\n const signal = lifetime?.signal ?? context.signal\n try { return await action.run({ ...context, signal }, parsed) } catch (error) {\n if (error instanceof MobileExtensionError) throw error\n throw new MobileExtensionError('extension_failed', 'extension action failed', 500)\n } finally { lifetime?.cleanup() }\n }\n\n /** Match one route and invoke it with a generation-bound abort signal. */\n async route(id: string, method: string, pathname: string, request: MobileRouteRequest, generation?: string): Promise<MobileRouteResponse> {\n const extension = this.extension(id, generation)\n if (extension === undefined) throw new MobileExtensionError('extension_not_found', 'extension not found', 404)\n const definition = 'host' in extension ? extension.host : extension\n const route = definition.routes?.find((candidate: MobileHostRoute) => {\n if (candidate.method !== method) return false\n return (candidate.kind ?? 'exact') === 'exact'\n ? candidate.path === pathname\n : pathname === candidate.path || pathname.startsWith(`${candidate.path}/`)\n })\n if (route === undefined) throw new MobileExtensionError('route_not_found', 'route not found', 404)\n const lifetime = 'host' in extension ? combineSignalLifetime(extension.controller.signal, request.signal) : undefined\n let releaseLifetime = true\n try {\n const routeRequest = lifetime === undefined ? request : { ...request, signal: lifetime.signal }\n const result = await route.handle(routeRequest)\n if (result === null || typeof result !== 'object' || typeof result.body !== 'string' && !(result.body instanceof Uint8Array) && !isReadable(result.body)) {\n throw new MobileExtensionError('invalid_route_response', 'extension returned an invalid response', 500)\n }\n if (lifetime !== undefined && isReadable(result.body)) {\n releaseLifetime = false\n releaseSignalLifetimeWhenStreamSettles(result.body, lifetime.cleanup)\n }\n return result\n } catch (error) {\n if (error instanceof MobileExtensionError) throw error\n throw new MobileExtensionError('extension_failed', 'extension route failed', 500)\n } finally { if (releaseLifetime) lifetime?.cleanup() }\n }\n\n /** Start the local directory watcher; an absent directory is intentionally inert. */\n async startLocal(root: string, context: Context): Promise<void> {\n const targetRoot = resolve(root)\n if (this.localRoot !== undefined && resolve(this.localRoot) !== targetRoot) await this.stopLocal()\n if (this.localTimer !== undefined) clearInterval(this.localTimer)\n const lifecycle = ++this.localLifecycle\n this.localRoot = targetRoot; this.localContext = context; this.localClosed = false\n await mkdir(this.localRoot, { recursive: true })\n if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return\n await this.refreshLocal()\n if (this.localClosed || this.localLifecycle !== lifecycle || this.localRoot !== targetRoot || this.localContext !== context) return\n this.localTimer = setInterval(() => { void this.refreshLocal() }, 2_000)\n this.localTimer.unref()\n }\n\n /** Stop the watcher and abort every local host generation. */\n async stopLocal(): Promise<void> {\n this.localClosed = true\n const lifecycle = ++this.localLifecycle\n if (this.localTimer !== undefined) clearInterval(this.localTimer)\n this.localTimer = undefined\n const refreshing = this.localRefreshing\n this.localRefreshAbort?.abort()\n const previous = [...this.local.values(), ...[...this.retired.values()].map(entry => entry.active)]\n this.local.clear()\n for (const entry of this.retired.values()) clearTimeout(entry.timer)\n this.retired.clear()\n this.failures.clear()\n this.updateContentHash()\n await Promise.allSettled([\n abortAndDisposeLocal(previous),\n ...(refreshing === undefined ? [] : [refreshing]),\n ])\n if (this.localLifecycle !== lifecycle) return\n const late = [...this.local.values(), ...[...this.retired.values()].map(entry => entry.active)]\n this.local.clear()\n for (const entry of this.retired.values()) clearTimeout(entry.timer)\n this.retired.clear()\n this.failures.clear()\n this.updateContentHash()\n await abortAndDisposeLocal(late)\n if (this.localTimer !== undefined) clearInterval(this.localTimer)\n this.localTimer = undefined\n }\n\n /** Refresh all local extensions atomically; failures keep the previous snapshot. */\n refreshLocal(): Promise<void> {\n if (this.localRefreshing !== undefined) return this.localRefreshing\n const controller = new AbortController()\n this.localRefreshAbort = controller\n const refreshing = this.stageAndCommit(controller.signal).finally(() => {\n if (this.localRefreshing === refreshing) this.localRefreshing = undefined\n if (this.localRefreshAbort === controller) this.localRefreshAbort = undefined\n })\n this.localRefreshing = refreshing\n return refreshing\n }\n\n private async stageAndCommit(signal: AbortSignal): Promise<void> {\n if (this.localClosed || signal.aborted || this.localRoot === undefined || this.localContext === undefined) return\n let names: string[] = []\n try {\n const directory = await opendir(this.localRoot)\n try { for await (const entry of directory) if (entry.isDirectory() && !entry.isSymbolicLink()) names.push(entry.name) }\n finally { await directory.close().catch(() => undefined) }\n } catch { return }\n names.sort()\n const staged: ActiveLocalExtension[] = []\n const stagedFresh: ActiveLocalExtension[] = []\n let failingName = 'local'\n try {\n for (const name of names) {\n signal.throwIfAborted()\n failingName = name\n const directory = join(this.localRoot, name)\n const fingerprint = await extensionFingerprint(directory)\n const current = this.local.get(fingerprint.manifest.id)\n const retired = this.retired.get(fingerprint.manifest.id)?.active\n const previous = current?.digest === fingerprint.digest ? current : retired?.digest === fingerprint.digest ? retired : undefined\n if (previous?.digest === fingerprint.digest) staged.push(previous)\n else {\n const fresh = await loadLocalExtension(directory, this.localContext, fingerprint, signal)\n try {\n signal.throwIfAborted()\n const confirmed = await extensionFingerprint(directory)\n if (confirmed.digest !== fingerprint.digest) {\n throw new MobileExtensionError('extension_changed_during_activation', `extension ${fingerprint.manifest.id} changed during activation`, 409)\n }\n } catch (error) {\n await abortAndDisposeLocal([fresh])\n throw error\n }\n staged.push(fresh); stagedFresh.push(fresh)\n }\n }\n if (this.localClosed || signal.aborted || this.localRoot === undefined || this.localContext === undefined) {\n await abortAndDisposeLocal(stagedFresh)\n return\n }\n const duplicate = new Set<string>()\n for (const entry of staged) {\n if (duplicate.has(entry.manifest.id) || this.registered.has(entry.manifest.id)) throw new MobileExtensionError('duplicate_extension', `duplicate extension id ${entry.manifest.id}`)\n duplicate.add(entry.manifest.id)\n }\n const previous = [...this.local.values()]\n for (const entry of staged) {\n const retired = this.retired.get(entry.manifest.id)\n if (retired?.active === entry) {\n clearTimeout(retired.timer)\n this.retired.delete(entry.manifest.id)\n }\n }\n const stagedIds = new Set(staged.map(entry => entry.manifest.id))\n const removed: ActiveLocalExtension[] = []\n for (const entry of previous) {\n if (staged.includes(entry)) continue\n if (stagedIds.has(entry.manifest.id)) {\n this.retire(entry)\n continue\n }\n removed.push(entry)\n const retired = this.retired.get(entry.manifest.id)\n if (retired !== undefined) {\n clearTimeout(retired.timer)\n this.retired.delete(entry.manifest.id)\n removed.push(retired.active)\n }\n }\n this.local.clear()\n for (const entry of staged) this.local.set(entry.manifest.id, entry)\n for (const entry of staged) this.failures.delete(entry.manifest.id)\n for (const name of names) this.failures.delete(name)\n for (const failure of this.failures.keys()) {\n if (failure !== 'local' && !names.includes(failure)) this.failures.delete(failure)\n }\n this.failures.delete('local')\n if (removed.length > 0) void abortAndDisposeLocal(removed)\n this.updateContentHash()\n } catch (error) {\n await abortAndDisposeLocal(stagedFresh)\n if (this.localClosed || signal.aborted) return\n const message = error instanceof Error ? error.message : String(error)\n this.failures.set(failingName, message)\n if (!(error instanceof MobileExtensionError)) this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))\n }\n }\n\n private retire(active: ActiveLocalExtension): void {\n const previous = this.retired.get(active.manifest.id)\n if (previous?.active === active) return\n if (previous !== undefined) {\n clearTimeout(previous.timer)\n this.retired.delete(active.manifest.id)\n void abortAndDisposeLocal([previous.active])\n }\n const timer = setTimeout(() => {\n const current = this.retired.get(active.manifest.id)\n if (current?.active !== active) return\n this.retired.delete(active.manifest.id)\n void abortAndDisposeLocal([active])\n }, RETIRED_GENERATION_TTL_MS)\n timer.unref()\n this.retired.set(active.manifest.id, { active, timer })\n }\n}\n\nfunction isReadable(value: unknown): value is Readable {\n return value !== null && typeof value === 'object' && typeof (value as { pipe?: unknown }).pipe === 'function'\n}\n\nfunction releaseSignalLifetimeWhenStreamSettles(stream: Readable, cleanup: () => void): void {\n let stopObserving: (() => void) | undefined\n stopObserving = finished(stream, () => {\n stopObserving?.()\n cleanup()\n })\n}\n\nfunction invokeCleanups(cleanups: readonly (() => void | Promise<void>)[]): Promise<unknown>[] {\n const pending: Promise<unknown>[] = []\n for (const cleanup of [...cleanups].reverse()) {\n try { pending.push(Promise.resolve(cleanup())) } catch { /* extension teardown cannot block the owner */ }\n }\n return pending\n}\n\nasync function settleBounded(pending: readonly Promise<unknown>[], timeoutMs: number): Promise<void> {\n if (pending.length === 0) return\n let timer: NodeJS.Timeout | undefined\n await Promise.race([\n Promise.allSettled(pending),\n new Promise<void>(resolveTimeout => { timer = setTimeout(resolveTimeout, timeoutMs) }),\n ])\n if (timer !== undefined) clearTimeout(timer)\n}\n\nasync function abortAndDisposeLocal(entries: readonly ActiveLocalExtension[]): Promise<void> {\n const pending: Promise<unknown>[] = []\n for (const entry of entries) {\n entry.controller.abort()\n pending.push(...invokeCleanups(entry.cleanups))\n }\n await settleBounded(pending, HOST_TEARDOWN_TIMEOUT_MS)\n}\n\nasync function loadLocalExtension(directory: string, context: Context, known?: LocalExtensionFingerprint, parentSignal?: AbortSignal): Promise<ActiveLocalExtension> {\n const root = await realExtensionRoot(directory)\n const manifestFile = await regularFile(join(root, 'extension.json'), EXTENSION_LIMITS.manifest, 'extension.json')\n const manifest = known?.manifest ?? parseExtensionManifest(JSON.parse(await readFile(manifestFile.path, 'utf8')) as unknown)\n if (manifest.id !== basename(root)) throw new MobileExtensionError('invalid_manifest', 'extension id must match its directory name')\n const scriptBody = known === undefined\n ? await optionalFile(root, 'mobile.js', EXTENSION_LIMITS.script, 'mobile.js').then(path => path === undefined ? undefined : readFile(path))\n : known.scriptBody\n const styleBody = known === undefined\n ? await optionalFile(root, 'mobile.css', EXTENSION_LIMITS.css, 'mobile.css').then(path => path === undefined ? undefined : readFile(path))\n : known.styleBody\n const assets = known?.assets ?? await assetSnapshot(root)\n const hostFile = await optionalFile(root, 'host.mjs', EXTENSION_LIMITS.script, 'host.mjs')\n const controller = new AbortController()\n const actions: Record<string, MobileHostAction> = {}\n const routes: MobileHostRoute[] = []\n const cleanups: (() => void | Promise<void>)[] = []\n const pendingEffects: Promise<void>[] = []\n let activationOpen = true\n const onParentAbort = (): void => { controller.abort(parentSignal?.reason) }\n if (parentSignal?.aborted === true) onParentAbort()\n else parentSignal?.addEventListener('abort', onParentAbort, { once: true })\n const ensureActivationOpen = (): void => {\n if (!activationOpen || controller.signal.aborted) throw new MobileExtensionError('host_activation_closed', `extension ${manifest.id} activation is closed`, 409)\n }\n const api: HostApi = {\n manifest,\n context,\n schema: z,\n signal: controller.signal,\n action(name, spec) { ensureActivationOpen(); if (actions[name] !== undefined) throw new MobileExtensionError('duplicate_action', `duplicate action ${name}`); actions[name] = spec },\n route(spec) { ensureActivationOpen(); routes.push(spec) },\n effect(setup) {\n ensureActivationOpen()\n const result = setup()\n if (result instanceof Promise) {\n pendingEffects.push(result.then(async cleanup => {\n if (typeof cleanup !== 'function') return\n if (activationOpen) cleanups.push(cleanup)\n else await cleanup()\n }))\n } else if (typeof result === 'function') {\n if (activationOpen) cleanups.push(result)\n else void Promise.resolve(result()).catch(() => undefined)\n }\n },\n }\n try {\n const activate = async (): Promise<void> => {\n controller.signal.throwIfAborted()\n if (hostFile !== undefined) {\n const digest = createHash('sha256').update(await readFile(hostFile)).digest('hex')\n controller.signal.throwIfAborted()\n let imported: LocalHostModule\n try { imported = await import(`${pathToFileURL(hostFile).href}?dsh_generation=${digest}`) as LocalHostModule }\n catch { throw new MobileExtensionError('host_load_failed', `could not load ${manifest.id}/host.mjs`, 500) }\n controller.signal.throwIfAborted()\n if (imported.default !== undefined) await imported.default(api)\n }\n await Promise.all(pendingEffects)\n }\n await withActivationTimeout(activate(), manifest.id, controller.signal)\n const host = validateDefinition({ ...manifest, actions, routes })\n activationOpen = false\n const digest = known?.digest ?? createHash('sha256').update(manifest.id).digest('hex')\n return Object.freeze({ manifest, directory: root, ...(scriptBody === undefined ? {} : { scriptBody }), ...(styleBody === undefined ? {} : { styleBody }), assets, host, controller, cleanups: Object.freeze(cleanups), digest })\n } catch (error) {\n activationOpen = false\n controller.abort()\n const cleanupPromises = invokeCleanups(cleanups.splice(0))\n await settleBounded([...pendingEffects, ...cleanupPromises], HOST_TEARDOWN_TIMEOUT_MS)\n throw error\n } finally {\n parentSignal?.removeEventListener('abort', onParentAbort)\n }\n}\n\n/** Construct the service in a Cordis plugin without importing DSH internals. */\nexport function createMobileAccessService(ctx: Context): MobileAccessService {\n return new MobileAccessService(ctx)\n}\n","#!/usr/bin/env node\nimport { execFileText as execFile } from './exec-file.js'\nimport { mkdir, readFile, rm, writeFile } from 'node:fs/promises'\nimport { homedir } from 'node:os'\nimport { join, resolve } from 'node:path'\nimport {\n ensureManagedCa,\n preferredLanInterfaceNames,\n refreshManagedServerCertificate,\n selectLanNetwork,\n type ManagedSetup,\n} from './managed-setup.js'\nimport { assertExtensionId } from './extensions.js'\nimport { restrictPrivateFile } from './private-file.js'\n\ninterface SetupOptions {\n readonly address?: string\n readonly port: number\n readonly dshPort: number\n readonly configureFirewall: boolean\n}\n\nconst FIREWALL_TCP_RULE = 'DSH Mobile HTTPS'\nconst FIREWALL_UDP_RULE = 'DSH Mobile Discovery'\n\nfunction parseOptions(args: readonly string[]): SetupOptions {\n let address: string | undefined\n let port = 3443\n let dshPort = 3080\n let configureFirewall = true\n for (let index = 0; index < args.length; index += 1) {\n const name = args[index]\n const value = args[index + 1]\n if (name === '--address' && value !== undefined) {\n address = value\n index += 1\n continue\n }\n if (name === '--port' && value !== undefined) {\n port = Number(value)\n index += 1\n continue\n }\n if (name === '--dsh-port' && value !== undefined) {\n dshPort = Number(value)\n index += 1\n continue\n }\n if (name === '--no-firewall') {\n configureFirewall = false\n continue\n }\n throw new Error(`unknown setup option: ${name ?? ''}`)\n }\n if (!Number.isSafeInteger(port) || port < 1024 || port > 65535) throw new Error('--port must be from 1024 through 65535')\n if (!Number.isSafeInteger(dshPort) || dshPort < 1024 || dshPort > 65535) throw new Error('--dsh-port must be from 1024 through 65535')\n return { ...(address === undefined ? {} : { address }), port, dshPort, configureFirewall }\n}\n\nfunction dshHome(): string {\n return resolve(process.env.DSH_HOME ?? join(homedir(), '.dsh'))\n}\n\nasync function runElevatedPowerShell(script: string): Promise<void> {\n const encoded = Buffer.from(script, 'utf16le').toString('base64')\n const launch = [\n \"$ErrorActionPreference = 'Stop'; $process = Start-Process -FilePath 'powershell.exe' -Verb RunAs -WindowStyle Hidden -Wait -PassThru\",\n ` -ArgumentList @('-NoProfile','-NonInteractive','-EncodedCommand','${encoded}')`,\n '; exit $process.ExitCode',\n ].join(' ')\n await execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', launch], { windowsHide: true })\n}\n\nasync function configureWindowsFirewall(port: number): Promise<void> {\n if (process.platform !== 'win32') return\n const script = [\n \"$ErrorActionPreference = 'Stop'\",\n `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n `New-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -Direction Inbound -Action Allow -Protocol TCP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,\n `New-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -Direction Inbound -Action Allow -Protocol UDP -LocalPort ${String(port)} -RemoteAddress LocalSubnet -Profile Any | Out-Null`,\n ].join('; ')\n console.log('Windows will request administrator approval for two LAN-only firewall rules.')\n await runElevatedPowerShell(script)\n}\n\nasync function removeWindowsFirewall(): Promise<void> {\n if (process.platform !== 'win32') return\n await runElevatedPowerShell([\n \"$ErrorActionPreference = 'Stop'\",\n `Get-NetFirewallRule -DisplayName '${FIREWALL_TCP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n `Get-NetFirewallRule -DisplayName '${FIREWALL_UDP_RULE}' -ErrorAction SilentlyContinue | Remove-NetFirewallRule`,\n ].join('; '))\n}\n\nasync function setup(args: readonly string[]): Promise<void> {\n const options = parseOptions(args)\n const preferredInterfaces = options.address === undefined ? await preferredLanInterfaceNames() : []\n const network = selectLanNetwork(options.address, undefined, undefined, preferredInterfaces)\n const home = dshHome()\n const directory = join(home, 'mobile-access')\n const tls = join(directory, 'tls')\n await mkdir(tls, { recursive: true, mode: 0o700 })\n\n const legacyCertFile = join(tls, 'cert.pem')\n const legacyKeyFile = join(tls, 'key.pem')\n const certFile = join(tls, 'server-cert.pem')\n const keyFile = join(tls, 'server-key.pem')\n const caCertFile = join(tls, 'ca.pem')\n const caKeyFile = join(tls, 'ca-key.pem')\n const androidCertificate = join(tls, 'dsh-mobile-ca.cer')\n const managedTls: ManagedSetup['tls'] = {\n mode: 'managed',\n caCertFile,\n caKeyFile,\n certFile,\n keyFile,\n }\n const ca = await ensureManagedCa(managedTls, { certFile: legacyCertFile, keyFile: legacyKeyFile })\n const managedSetup: ManagedSetup = {\n version: 2,\n networkInterface: network.name,\n listenPort: options.port,\n upstreamOrigin: `http://127.0.0.1:${String(options.dshPort)}`,\n tls: managedTls,\n }\n await refreshManagedServerCertificate(managedSetup, network.address)\n await writeFile(androidCertificate, ca.raw, { mode: 0o600 })\n await Promise.all([\n restrictPrivateFile(caCertFile),\n restrictPrivateFile(caKeyFile),\n restrictPrivateFile(certFile),\n restrictPrivateFile(keyFile),\n restrictPrivateFile(androidCertificate),\n ])\n if (options.configureFirewall) await configureWindowsFirewall(options.port)\n\n const customCss = join(directory, 'mobile.css')\n try {\n await readFile(customCss)\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n await writeFile(customCss, [\n '/* Safe mobile overrides. DSH Mobile applies saved changes on the phone automatically. */',\n ':root {',\n ' --dsh-mobile-accent: #2563eb;',\n ' --dsh-mobile-font-scale: 1;',\n ' --dsh-mobile-radius: 14px;',\n '}',\n '',\n ].join('\\n'), { mode: 0o600 })\n }\n\n const customScript = join(directory, 'mobile.js')\n try {\n await readFile(customScript)\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error\n await writeFile(customScript, [\n '/* Mount mobile-only Web features here. Saved changes are applied automatically. */',\n 'window.dshMobile.register(({ root }) => {',\n ' root.replaceChildren()',\n ' return () => root.replaceChildren()',\n '})',\n '',\n ].join('\\n'), { mode: 0o600 })\n }\n\n const extensions = join(directory, 'extensions')\n await mkdir(extensions, { recursive: true, mode: 0o700 })\n await createExtensionScaffold(extensions, 'custom', '自定义移动扩展', false)\n\n const origin = `https://${network.address}:${String(options.port)}`\n await Promise.all([\n writeFile(join(directory, 'setup.json'), `${JSON.stringify({\n ...managedSetup,\n tls: Object.fromEntries(Object.entries(managedSetup.tls)\n .map(([key, value]) => [key, typeof value === 'string' ? value.replaceAll('\\\\', '/') : value])),\n }, null, 2)}\\n`, { mode: 0o600 }),\n writeFile(join(directory, 'control.json'), '{\"version\":1,\"enabled\":true}\\n', { mode: 0o600 }),\n ])\n await Promise.all([\n restrictPrivateFile(join(directory, 'setup.json')),\n restrictPrivateFile(join(directory, 'control.json')),\n ])\n\n console.log(`DSH Mobile follows ${network.name} and is currently configured for ${origin}`)\n console.log(`Install this CA certificate on Android once: ${androidCertificate}`)\n console.log(`Ask DSH to customize the mobile Web UI and features in: ${customCss} and ${customScript}`)\n console.log(`Additional extensions live in: ${extensions}`)\n console.log('Start DSH with: dsh --profile web')\n console.log('Then open the Mobile card in the lower-left corner and create a pairing key.')\n}\n\nasync function createExtensionScaffold(root: string, id: string, name: string, refuseExisting = true): Promise<void> {\n assertExtensionId(id)\n await mkdir(root, { recursive: true, mode: 0o700 })\n const directory = join(root, id)\n try {\n await mkdir(directory, { recursive: false, mode: 0o700 })\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code === 'EEXIST' && !refuseExisting) return\n if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error(`extension directory already exists: ${id}`)\n throw error\n }\n const files: Readonly<Record<string, string>> = {\n 'extension.json': `${JSON.stringify({ schemaVersion: 1, id, name, version: '0.1.0', description: '在手机端扩展 DSH' }, null, 2)}\\n`,\n 'host.mjs': `export default async function activate(api) {\\n api.action('hello', {\\n input: api.schema.object({ name: api.schema.string().max(80) }),\\n async run({ signal, deviceId }, input) {\\n void signal; void deviceId\\n return { message: \\`Hello, \\${input.name}\\` }\\n },\\n })\\n}\\n`,\n 'mobile.js': `window.dshMobile?.define?.({\\n apiVersion: 1,\\n id: '${id}',\\n activate(api) {\\n return api.ui.registerSurface({\\n id: '${id}-page', placement: 'page', label: ${JSON.stringify(name)},\\n mount(container) {\\n container.textContent = ${JSON.stringify(`这是 ${name} 的移动页面。`)}\\n return () => container.replaceChildren()\\n },\\n })\\n },\\n})\\n`,\n 'mobile.css': `/* ${name.replaceAll('*/', '* /')} 的移动端样式。保存后通常会在几秒内刷新。 */\\n`,\n }\n try {\n for (const [file, contents] of Object.entries(files)) await writeFile(join(directory, file), contents, { encoding: 'utf8', flag: 'wx', mode: 0o600 })\n } catch (error) {\n await rm(directory, { recursive: true, force: true })\n throw error\n }\n console.log(`Created extension: ${directory}`)\n}\n\nasync function extensionCommand(args: readonly string[]): Promise<void> {\n const [subcommand, id, ...rest] = args\n if (subcommand !== 'create' || id === undefined) throw new Error('usage: extension create <id> [--name <name>]')\n let name = id\n for (let index = 0; index < rest.length; index += 1) {\n if (rest[index] === '--name' && rest[index + 1] !== undefined) { name = rest[index + 1]!; index += 1; continue }\n throw new Error(`unknown extension option: ${rest[index] ?? ''}`)\n }\n if (name.length === 0 || name.length > 120 || /[\\u0000-\\u001f\\u007f]/u.test(name)) throw new Error('--name is invalid')\n await createExtensionScaffold(join(dshHome(), 'mobile-access', 'extensions'), id, name)\n}\n\nasync function purge(args: readonly string[]): Promise<void> {\n if (args.length !== 1 || args[0] !== '--yes') throw new Error('purge requires --yes')\n const home = dshHome()\n await rm(join(home, 'mobile-access'), { recursive: true, force: true })\n await removeWindowsFirewall()\n console.log('Removed DSH Mobile certificates, devices, preferences, and custom Web files.')\n}\n\nfunction help(): void {\n console.log([\n 'dsh-mobile setup [--address 192.168.x.x] [--port 3443] [--dsh-port 3080] [--no-firewall]',\n 'dsh-mobile extension create <id> [--name <name>]',\n 'dsh-mobile purge --yes',\n '',\n 'Run through the DSH profile:',\n ' dsh plugin --profile web exec dsh-mobile setup',\n ].join('\\n'))\n}\n\nasync function main(): Promise<void> {\n const [command = 'help', ...args] = process.argv.slice(2)\n if (command === 'setup') await setup(args)\n else if (command === 'extension') await extensionCommand(args)\n else if (command === 'purge') await purge(args)\n else if (command === 'help' || command === '--help' || command === '-h') help()\n else throw new Error(`unknown command: ${command}`)\n}\n\nmain().catch((error: unknown) => {\n console.error(error instanceof Error ? error.message : String(error))\n process.exitCode = 1\n})\n"],"mappings":"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"}
|
package/lib/client.js
CHANGED
|
@@ -110,7 +110,7 @@ window.__ModuleLoader__.load({
|
|
|
110
110
|
cpolarUnsupported: "Only Windows x64 is currently supported. You can still use the built-in Tailscale Funnel.",
|
|
111
111
|
cpolarNotInstalled: "Not installed. A fixed version is downloaded from the official cpolar website only after you click the button below.",
|
|
112
112
|
cpolarNeedsToken: "Official component {version} is verified. Save the account token to continue.",
|
|
113
|
-
cpolarReady: "Official component {version} and the local account configuration are ready.",
|
|
113
|
+
cpolarReady: "Official component {version} and the local account configuration are ready. Free temporary addresses may change after DSH or cpolar restarts; if the app cannot connect, scan the current remote QR code again.",
|
|
114
114
|
remoteOff: "Remote access is disabled. Local network access is unaffected.",
|
|
115
115
|
remoteUnavailableCpolar: "cpolar is not installed or its local account is not configured.",
|
|
116
116
|
remoteUnavailableTailscale: "This computer is missing the Funnel runtime component. Reinstall the complete plugin package.",
|
|
@@ -277,7 +277,7 @@ window.__ModuleLoader__.load({
|
|
|
277
277
|
cpolarUnsupported: "Attualmente è supportato solo Windows x64. Puoi comunque usare Tailscale Funnel integrato.",
|
|
278
278
|
cpolarNotInstalled: "Non installato. Una versione fissa viene scaricata dal sito ufficiale cpolar solo dopo aver premuto il pulsante.",
|
|
279
279
|
cpolarNeedsToken: "Componente ufficiale {version} verificato. Salva il token account per continuare.",
|
|
280
|
-
cpolarReady: "Componente ufficiale {version} e configurazione account locale pronti.",
|
|
280
|
+
cpolarReady: "Componente ufficiale {version} e configurazione account locale pronti. Gli indirizzi temporanei gratuiti possono cambiare dopo il riavvio di DSH o cpolar; se l’app non si connette, scansiona di nuovo il QR remoto corrente.",
|
|
281
281
|
remoteOff: "Accesso remoto disattivato. La rete locale non è interessata.",
|
|
282
282
|
remoteUnavailableCpolar: "cpolar non è installato o l’account locale non è configurato.",
|
|
283
283
|
remoteUnavailableTailscale: "Su questo computer manca il componente runtime Funnel. Reinstalla il pacchetto completo del plugin.",
|
|
@@ -446,7 +446,7 @@ window.__ModuleLoader__.load({
|
|
|
446
446
|
cpolarUnsupported: "当前仅支持 Windows x64。你仍可选择内置的 Tailscale Funnel。",
|
|
447
447
|
cpolarNotInstalled: "尚未安装。只有点击下方按钮后,才会从 cpolar 官网下载固定版本。",
|
|
448
448
|
cpolarNeedsToken: "官方组件 {version} 已校验,下一步只需保存账号令牌。",
|
|
449
|
-
cpolarReady: "官方组件 {version}
|
|
449
|
+
cpolarReady: "官方组件 {version} 与本机账号配置已就绪。免费临时地址可能在 DSH 或 cpolar 重启后变化;App 无法连接时,请重新扫描当前远程二维码。",
|
|
450
450
|
remoteOff: "远程访问未启用。局域网访问不受影响。",
|
|
451
451
|
remoteUnavailableCpolar: "cpolar 尚未安装或未完成本机账号配置。",
|
|
452
452
|
remoteUnavailableTailscale: "当前电脑缺少 Funnel 运行组件,请重新安装完整插件包。",
|
|
@@ -1131,9 +1131,25 @@ window.__ModuleLoader__.load({
|
|
|
1131
1131
|
}
|
|
1132
1132
|
//#endregion
|
|
1133
1133
|
//#region src/native-mobile.ts
|
|
1134
|
+
/**
|
|
1135
|
+
* Viewport below which the injected chrome becomes an overlay drawer: the
|
|
1136
|
+
* sidebar slides in as a sheet, the right panel as a drawer, and the scrim dims
|
|
1137
|
+
* the page behind them. The stylesheet and the scrim's visibility both read
|
|
1138
|
+
* this query, so a wider viewport never grows drawer chrome whose rules cannot
|
|
1139
|
+
* style it.
|
|
1140
|
+
*/
|
|
1141
|
+
const NATIVE_MOBILE_OVERLAY_QUERY = "(max-width:720px)";
|
|
1134
1142
|
/** Mobile feature and compatibility rules applied to DSH React surfaces. */
|
|
1135
1143
|
const NATIVE_MOBILE_STYLES = `
|
|
1136
|
-
|
|
1144
|
+
/* The surface appends chrome to <body> on every non-loopback page load, but
|
|
1145
|
+
every rule that gives that chrome a box lives inside the overlay query
|
|
1146
|
+
below. Outside the query the scrim kept the UA button box: an empty,
|
|
1147
|
+
nameless button in normal flow at the document's bottom-left, whose click
|
|
1148
|
+
still collapsed the sidebar. Keep the neutral state explicitly invisible —
|
|
1149
|
+
the query restores the fixed scrim, and its more specific [hidden] rule
|
|
1150
|
+
keeps winning there. */
|
|
1151
|
+
.dsh-native-mobile-backdrop,.dsh-mobile-branch-toast,.dsh-mobile-media-toast { display:none; }
|
|
1152
|
+
@media ${NATIVE_MOBILE_OVERLAY_QUERY} {
|
|
1137
1153
|
html.dsh-native-mobile-active,html.dsh-native-mobile-active body { width:100%; height:100%; overflow:hidden; }
|
|
1138
1154
|
html.dsh-native-mobile-active { --dsh-mobile-motion-duration:200ms; --dsh-mobile-motion-ease:cubic-bezier(.22,1,.36,1); }
|
|
1139
1155
|
html.dsh-native-mobile-active :is(a,button,[role="button"],[role="tab"],[tabindex]) { -webkit-tap-highlight-color:transparent; }
|
|
@@ -1178,7 +1194,7 @@ window.__ModuleLoader__.load({
|
|
|
1178
1194
|
[data-dsh-mobile-sidebar][data-open="false"] [data-dsh-mobile-sidebar-root] > :has([data-dsh-mobile-toggle]) > :not([data-dsh-mobile-toggle]) { display:none !important; }
|
|
1179
1195
|
[data-dsh-mobile-toggle] { width:44px !important; height:44px !important; min-width:44px !important; min-height:44px !important; }
|
|
1180
1196
|
[data-dsh-mobile-sidebar][data-open="false"] [data-dsh-mobile-toggle] > svg[class*="_railFish"] { transform:translateY(-4px) !important; }
|
|
1181
|
-
.dsh-native-mobile-backdrop { position:fixed; z-index:235; inset:env(safe-area-inset-top) 0 0; border:0; background:rgb(15 23 42 / 32%); }
|
|
1197
|
+
.dsh-native-mobile-backdrop { display:block; position:fixed; z-index:235; inset:env(safe-area-inset-top) 0 0; border:0; background:rgb(15 23 42 / 32%); }
|
|
1182
1198
|
.dsh-native-mobile-backdrop:not([hidden]) { animation:dsh-mobile-fade-in var(--dsh-mobile-motion-duration) ease-out; }
|
|
1183
1199
|
.dsh-native-mobile-backdrop[hidden] { display:none; }
|
|
1184
1200
|
[data-dsh-mobile-details] { position:fixed !important; z-index:250 !important; inset:0 0 0 auto !important; width:min(94vw,460px) !important; max-width:none !important; transform:translateX(100%); transition:transform var(--dsh-mobile-motion-duration) var(--dsh-mobile-motion-ease); background:var(--dsw-bg, #fff); box-shadow:-18px 0 46px rgb(15 23 42 / 18%); }
|
|
@@ -1234,6 +1250,8 @@ window.__ModuleLoader__.load({
|
|
|
1234
1250
|
[data-dsh-mobile-message-column] [data-context-fields] > * { display:grid !important; grid-template-columns:minmax(72px,30%) minmax(0,1fr) !important; gap:4px 10px !important; }
|
|
1235
1251
|
[data-dsh-mobile-message-column] [class*="_ioSection"] { grid-template-columns:1fr !important; row-gap:4px !important; }
|
|
1236
1252
|
[data-dsh-mobile-message-column] [class*="_body"] { max-width:100% !important; overflow-wrap:anywhere; }
|
|
1253
|
+
/* Keep folded and expanded reasoning visually separate from the reply. */
|
|
1254
|
+
[data-dsh-mobile-message-column] [class*="_body"] > div:has(> [data-variant="think"]) { margin-bottom:12px !important; }
|
|
1237
1255
|
[data-dsh-mobile-center] [data-composer-card] ~ [class*="_root"],
|
|
1238
1256
|
[data-dsh-mobile-center] [data-composer-card] ~ * [class*="_root"] { box-sizing:border-box !important; width:100% !important; max-width:100% !important; margin-bottom:-6px !important; padding:3px 4px 0 !important; font-size:11px !important; line-height:18px !important; white-space:normal !important; overflow:visible !important; text-overflow:clip !important; }
|
|
1239
1257
|
[data-dsh-mobile-center] [data-composer-card] ~ [class*="_root"] [class*="_sep"],
|
|
@@ -1261,7 +1279,7 @@ window.__ModuleLoader__.load({
|
|
|
1261
1279
|
[data-dsh-mobile-center] [role="dialog"][aria-label*="Context"] [class*="_rows"] > [class*="_row"] { display:flex !important; align-items:center !important; justify-content:space-between !important; width:100% !important; min-width:0 !important; white-space:nowrap !important; }
|
|
1262
1280
|
[data-dsh-mobile-center] [role="dialog"][aria-label*="上下文"] :is(dt,dd),
|
|
1263
1281
|
[data-dsh-mobile-center] [role="dialog"][aria-label*="Context"] :is(dt,dd) { white-space:nowrap !important; word-break:keep-all !important; }
|
|
1264
|
-
.dsh-mobile-branch-toast,.dsh-mobile-media-toast { position:fixed; z-index:330; top:max(12px,env(safe-area-inset-top)); left:50%; max-width:calc(100vw - 32px); box-sizing:border-box; padding:7px 14px; border:1px solid rgb(15 23 42 / 10%); border-radius:999px; background:rgb(15 23 42 / 92%); color:#fff; font-size:13px; line-height:20px; text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; opacity:0; pointer-events:none; transform:translate(-50%,-8px); transition:opacity 160ms ease,transform 160ms ease; }
|
|
1282
|
+
.dsh-mobile-branch-toast,.dsh-mobile-media-toast { display:block; position:fixed; z-index:330; top:max(12px,env(safe-area-inset-top)); left:50%; max-width:calc(100vw - 32px); box-sizing:border-box; padding:7px 14px; border:1px solid rgb(15 23 42 / 10%); border-radius:999px; background:rgb(15 23 42 / 92%); color:#fff; font-size:13px; line-height:20px; text-align:center; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; opacity:0; pointer-events:none; transform:translate(-50%,-8px); transition:opacity 160ms ease,transform 160ms ease; }
|
|
1265
1283
|
.dsh-mobile-branch-toast[data-visible="true"],.dsh-mobile-media-toast[data-visible="true"] { opacity:1; transform:translate(-50%,0); }
|
|
1266
1284
|
.dsh-mobile-media-shortcuts { box-sizing:border-box; display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; width:100%; padding:4px 4px 8px; margin-bottom:4px; border-bottom:1px solid var(--dsw-alias-border-inverted,var(--dsw-alias-border-subtle,rgb(148 163 184 / 28%))); }
|
|
1267
1285
|
.dsh-mobile-media-action { box-sizing:border-box; display:flex; align-items:center; justify-content:flex-start; gap:8px; width:100%; min-width:0; min-height:44px; padding:8px 10px; border:0; border-radius:10px; background:var(--dsw-alias-interactive-bg-hover,rgb(148 163 184 / 12%)); color:var(--dsw-alias-label-primary,inherit); cursor:pointer; font:inherit; font-size:14px; line-height:22px; text-align:left; touch-action:manipulation; }
|
|
@@ -1375,6 +1393,18 @@ window.__ModuleLoader__.load({
|
|
|
1375
1393
|
function shouldAutoLoadEarlier(previousTop, currentTop) {
|
|
1376
1394
|
return currentTop <= AUTO_HISTORY_THRESHOLD_PX && currentTop < previousTop - .5;
|
|
1377
1395
|
}
|
|
1396
|
+
/**
|
|
1397
|
+
* Whether the overlay scrim belongs on screen. The scrim exists for the
|
|
1398
|
+
* slide-in drawer only — it dims the page and catches the tap that closes the
|
|
1399
|
+
* drawer — so it is shown while that drawer is open *and* the overlay query is
|
|
1400
|
+
* in force. `hidden` carries the whole decision rather than the width rules
|
|
1401
|
+
* alone: the attribute still hides the element when the injected stylesheet
|
|
1402
|
+
* never lands (a CSP without inline styles, a shell that drops the <style>
|
|
1403
|
+
* node), where the element would otherwise fall back to the UA button box.
|
|
1404
|
+
*/
|
|
1405
|
+
function drawerScrimVisible(sidebarCollapsed, overlayActive) {
|
|
1406
|
+
return !sidebarCollapsed && overlayActive;
|
|
1407
|
+
}
|
|
1378
1408
|
/** Check that an asynchronous picker result still belongs to its originating session and composer. */
|
|
1379
1409
|
function isComposerMediaOriginCurrent(origin, current) {
|
|
1380
1410
|
return !current.disposed && origin.generation === current.generation && origin.href === current.href && origin.composer !== null && current.composerConnected && origin.composer === current.composer && origin.sessionRoot !== null && origin.sessionRoot === current.sessionRoot && origin.sessionId !== null && origin.sessionId === current.sessionId;
|
|
@@ -1434,6 +1464,7 @@ window.__ModuleLoader__.load({
|
|
|
1434
1464
|
};
|
|
1435
1465
|
document.addEventListener("pointerdown", onPointerDown, true);
|
|
1436
1466
|
document.addEventListener("keydown", onKeyDown, true);
|
|
1467
|
+
const overlayQuery = window.matchMedia(NATIVE_MOBILE_OVERLAY_QUERY);
|
|
1437
1468
|
const backdrop = document.createElement("button");
|
|
1438
1469
|
backdrop.type = "button";
|
|
1439
1470
|
backdrop.className = "dsh-native-mobile-backdrop";
|
|
@@ -1843,7 +1874,7 @@ window.__ModuleLoader__.load({
|
|
|
1843
1874
|
if (toggle !== void 0) toggle.dataset.dshMobileToggle = "true";
|
|
1844
1875
|
const collapsed = classToken(sidebarRoot, "_collapsed");
|
|
1845
1876
|
sidebar.dataset.open = String(!collapsed);
|
|
1846
|
-
backdrop.hidden = collapsed;
|
|
1877
|
+
backdrop.hidden = !drawerScrimVisible(collapsed, overlayQuery.matches);
|
|
1847
1878
|
};
|
|
1848
1879
|
const schedule = () => {
|
|
1849
1880
|
if (scheduled === 0) scheduled = requestAnimationFrame(sync);
|
|
@@ -1863,6 +1894,7 @@ window.__ModuleLoader__.load({
|
|
|
1863
1894
|
"data-dsh-mobile-session"
|
|
1864
1895
|
]
|
|
1865
1896
|
});
|
|
1897
|
+
overlayQuery.addEventListener("change", schedule);
|
|
1866
1898
|
backdrop.addEventListener("click", () => {
|
|
1867
1899
|
if (sidebar?.dataset.open === "true") toggle?.click();
|
|
1868
1900
|
});
|
|
@@ -1874,6 +1906,7 @@ window.__ModuleLoader__.load({
|
|
|
1874
1906
|
restoreLanguageMarker();
|
|
1875
1907
|
for (const cleanup of [...browserPickerCleanups]) cleanup();
|
|
1876
1908
|
observer.disconnect();
|
|
1909
|
+
overlayQuery.removeEventListener("change", schedule);
|
|
1877
1910
|
document.removeEventListener("click", onBranchClick, true);
|
|
1878
1911
|
fileButton.removeEventListener("pointerdown", quietMediaPointer);
|
|
1879
1912
|
cameraButton.removeEventListener("pointerdown", quietMediaPointer);
|
|
@@ -5299,7 +5332,7 @@ window.__ModuleLoader__.load({
|
|
|
5299
5332
|
@keyframes dsh-diagnostic-spin{to{transform:rotate(360deg)}}@keyframes dsh-diagnostic-reveal{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}
|
|
5300
5333
|
.dsh-mobile-control__actions{display:flex;flex-wrap:nowrap;gap:6px}.dsh-mobile-control__actions button{flex:1 1 0;min-width:0;min-height:40px;padding:8px 4px;border-radius:10px;font:12px/1.2 system-ui;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.dsh-mobile-control__secondary{border:1px solid var(--dsw-alias-border-normal,#cfd5dd);background:transparent;color:inherit}.dsh-mobile-control__primary{border:1px solid #2563eb;background:#2563eb;color:#fff}.dsh-mobile-control__actions button:disabled{cursor:not-allowed;opacity:.45}
|
|
5301
5334
|
.dsh-mobile-control button:focus-visible,.dsh-mobile-control a:focus-visible,.dsh-mobile-control input:focus-visible,.dsh-mobile-control summary:focus-visible{outline:3px solid rgb(37 99 235 / 28%);outline-offset:2px}
|
|
5302
|
-
.dsh-mobile-control__trigger{box-sizing:border-box;flex:
|
|
5335
|
+
.dsh-mobile-control__trigger{box-sizing:border-box;flex:1 1 auto;display:flex;align-items:center;gap:8px;width:100%;height:42px;margin:4px 0;padding:0 10px 0 8px;border:0;border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary,#16181d);font-family:inherit;font-size:14px;line-height:22px;cursor:pointer;overflow:hidden}.dsh-mobile-control__trigger:hover{background:var(--dsw-alias-interactive-bg-hover,#f1f3f6)}.dsh-mobile-control__trigger:active,.dsh-mobile-control__trigger[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-active,#e8ebf0)}.dsh-mobile-control__trigger:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,currentColor);outline-offset:2px}.dsh-mobile-control__trigger.is-rail{flex:0 0 auto;width:36px;height:36px;margin:8px 0 10px;padding:0;justify-content:center;gap:0;border-radius:50%}.dsh-mobile-control__trigger-icon{display:block;flex:none}.dsh-mobile-control__trigger-label{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
|
|
5303
5336
|
.dsh-mobile-control__manage-row{display:flex;justify-content:space-between;gap:8px;margin-top:10px}.dsh-mobile-control__manage{flex:1 1 0;min-width:0;min-height:34px;padding:6px 8px;border:1px solid var(--dsw-alias-border-normal,#cfd5dd);border-radius:10px;background:transparent;color:inherit;font:12px/1.3 system-ui;cursor:pointer}.dsh-mobile-control__devices{margin-top:10px;border:1px solid var(--dsw-alias-border-subtle,#e1e5eb);border-radius:10px;padding:8px;max-height:220px;overflow-y:auto}.dsh-mobile-control__device-empty{color:var(--dsw-alias-label-secondary,#606873);font-size:12px;margin:0}.dsh-mobile-control__device{display:flex;align-items:center;gap:8px;padding:6px 2px}.dsh-mobile-control__device + .dsh-mobile-control__device{border-top:1px solid var(--dsw-alias-border-subtle,#e1e5eb)}.dsh-mobile-control__device-label{flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.dsh-mobile-control__device-meta{flex:none;color:var(--dsw-alias-label-secondary,#606873);font-size:11px;white-space:nowrap}.dsh-mobile-control__device-revoke{flex:none;min-height:28px;padding:4px 8px;border:1px solid #dc2626;border-radius:8px;background:transparent;color:#dc2626;font:12px/1.2 system-ui;cursor:pointer}.dsh-mobile-control__ws-paths{margin:10px 0 0;padding:12px;border:1px solid var(--dsw-alias-border-subtle,#dbe1e8);border-radius:13px;background:var(--dsw-alias-bg-layer-2,#fff)}.dsh-mobile-control__ws-paths-list{margin:0 0 8px;padding:0;list-style:none}.dsh-mobile-control__ws-paths-empty{color:var(--dsw-alias-label-secondary,#606873);font-size:12px}.dsh-mobile-control__ws-paths-item{display:flex;align-items:center;gap:8px;padding:6px 0}.dsh-mobile-control__ws-paths-item + .dsh-mobile-control__ws-paths-item{border-top:1px solid var(--dsw-alias-border-subtle,#e1e5eb)}.dsh-mobile-control__ws-paths-item code{flex:1 1 0;min-width:0;overflow:hidden;font:11px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;text-overflow:ellipsis;white-space:nowrap}.dsh-mobile-control__ws-paths-remove{flex:none;min-height:28px;padding:4px 8px;border:1px solid var(--dsw-alias-border-normal,#cfd5dd);border-radius:8px;background:transparent;color:inherit;font:12px/1.2 system-ui;cursor:pointer}.dsh-mobile-control__trigger,.dsh-mobile-control__diagnostic-entry{position:relative}.dsh-mobile-control__ws-dot{position:absolute;top:2px;right:2px;z-index:1;min-width:16px;height:16px;padding:0 4px;border-radius:999px;background:#dc2626;color:#fff;font:650 10px/16px system-ui;text-align:center;cursor:pointer}.dsh-mobile-control__ws-dot::after{content:'';position:absolute;inset:-5px}.dsh-mobile-control__ws-sr-status{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.dsh-mobile-control__ws-paths-row{display:flex;gap:8px}.dsh-mobile-control__ws-paths-row .dsh-mobile-control__primary{width:auto;flex:none;padding:9px 14px;border-radius:10px}.dsh-mobile-control__ws-paths-group{padding:6px 0}.dsh-mobile-control__ws-paths-group + .dsh-mobile-control__ws-paths-group{border-top:1px solid var(--dsw-alias-border-subtle,#e1e5eb)}.dsh-mobile-control__ws-paths-group-head{display:flex;align-items:center;gap:8px}.dsh-mobile-control__ws-count{flex:none;padding:1px 7px;border-radius:999px;background:#e8f0ff;color:#1d4ed8;font:650 10px/1.6 system-ui}.dsh-mobile-control__ws-paths-item.is-child{padding-left:12px}.dsh-mobile-control__ws-paths-group-head .dsh-mobile-control__primary,.dsh-mobile-control__ws-paths-item .dsh-mobile-control__primary{width:auto;flex:none;min-height:34px;padding:6px 12px;border-radius:10px}.dsh-mobile-control__ws-paths-row input{flex:1 1 0;min-width:0;box-sizing:border-box;min-height:44px;padding:9px 10px;border:1px solid var(--dsw-alias-border-normal,#cfd5dd);border-radius:10px;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#16181d);font:16px/1.4 system-ui}.dsh-mobile-control__ws-paths-row button{flex:none}.dsh-mobile-control__ws-paths-detected-title{margin:0 0 2px;font:600 12px/1.4 system-ui}.dsh-mobile-control__ws-paths-advanced{margin-top:8px}.dsh-mobile-control__ws-paths-advanced summary{cursor:pointer;color:var(--dsw-alias-label-secondary,#606873);font:12px/1.4 system-ui;user-select:none}.dsh-mobile-control__ws-paths-advanced summary:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,currentColor);outline-offset:2px;border-radius:4px}.dsh-mobile-control__ws-paths-advanced[open] summary{margin-bottom:6px}.dsh-mobile-control__ws-paths-advanced .dsh-mobile-control__status{margin:6px 0 0}
|
|
5304
5337
|
@media (max-width:359px){.dsh-mobile-control__provider-choices{grid-template-columns:1fr}.dsh-mobile-control__provider{min-height:68px}}@media (prefers-reduced-motion:reduce){.dsh-mobile-control__provider,.dsh-mobile-control__cpolar-connect{transition:none}.dsh-mobile-control__diagnostic-summary.is-running .dsh-mobile-control__diagnostic-summary-icon::before,.dsh-mobile-control__diagnostic-checks{animation:none}}
|
|
5305
5338
|
`;
|