diff --git a/config/build-plugins/mobile-web-content-addressed.ts b/config/build-plugins/mobile-web-content-addressed.ts deleted file mode 100644 index 66e282d4fcf..00000000000 --- a/config/build-plugins/mobile-web-content-addressed.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { createHash } from 'node:crypto' -import { posix } from 'node:path' -import type { Plugin, Rolldown } from 'vite' -import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION } from '../../src/shared/mobile-web/bridge-protocol-version' -import { - MOBILE_WEB_MANIFEST_SCHEMA_VERSION, - MobileWebManifestSchema, - serializeMobileWebManifestForBuildId, - type MobileWebAsset, - type MobileWebManifest -} from '../../src/shared/mobile-web/manifest-contract' - -type OutputAsset = Rolldown.OutputAsset -type OutputBundle = Rolldown.OutputBundle -type OutputChunk = Rolldown.OutputChunk - -const CONTENT_TYPE_BY_EXTENSION = { - '.css': 'text/css; charset=utf-8', - '.html': 'text/html; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.png': 'image/png', - '.svg': 'image/svg+xml; charset=utf-8', - '.wasm': 'application/wasm', - '.webp': 'image/webp', - '.woff2': 'font/woff2' -} as const - -const ROLE_BY_EXTENSION = { - '.css': 'style', - '.html': 'document', - '.js': 'script', - '.png': 'image', - '.svg': 'image', - '.wasm': 'wasm', - '.webp': 'image', - '.woff2': 'font' -} as const - -export function createMobileWebContentAddressedPlugin(): Plugin { - return { - name: 'orca-mobile-web-content-addressed', - enforce: 'post', - generateBundle(_options, bundle) { - const entryChunks = Object.values(bundle).filter( - (item): item is OutputChunk => item.type === 'chunk' && item.isEntry - ) - if (entryChunks.length !== 1 || entryChunks[0]!.imports.length > 0) { - this.error('Mobile web build must emit one self-contained entry chunk') - } - if (entryChunks[0]!.dynamicImports.length > 0) { - this.error('Mobile web build cannot emit runtime-loaded JavaScript chunks') - } - - const renames = new Map() - const binaryAssets = Object.values(bundle).filter( - (item): item is OutputAsset => - item.type === 'asset' && !['.css', '.html'].includes(posix.extname(item.fileName)) - ) - for (const asset of binaryAssets) { - renameContentAddressedAsset(bundle, asset, assetBytes(asset), renames) - } - - const styles = Object.values(bundle).filter( - (item): item is OutputAsset => - item.type === 'asset' && posix.extname(item.fileName) === '.css' - ) - for (const style of styles) { - style.source = replaceAssetReferences(String(style.source), renames) - renameContentAddressedAsset(bundle, style, assetBytes(style), renames) - } - - const entry = entryChunks[0]! - entry.code = replaceAssetReferences(entry.code, renames) - renameContentAddressedChunk(bundle, entry, renames) - - const document = Object.values(bundle).find( - (item): item is OutputAsset => item.type === 'asset' && item.fileName === 'index.html' - ) - if (!document) { - this.error('Mobile web build did not emit index.html') - } - document.source = replaceAssetReferences(String(document.source), renames) - - const assets = manifestAssets(bundle) - const manifestWithoutIdentity: MobileWebManifest = { - schemaVersion: MOBILE_WEB_MANIFEST_SCHEMA_VERSION, - buildId: '0'.repeat(64), - bridge: { - minimum: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, - testedThrough: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION - }, - entrypoint: 'index.html', - totalBytes: assets.reduce((total, asset) => total + asset.byteLength, 0), - assets - } - const manifest = MobileWebManifestSchema.parse({ - ...manifestWithoutIdentity, - buildId: sha256(serializeMobileWebManifestForBuildId(manifestWithoutIdentity)) - }) - this.emitFile({ - type: 'asset', - fileName: 'manifest.json', - source: `${JSON.stringify(manifest, null, 2)}\n` - }) - } - } -} - -function renameContentAddressedAsset( - bundle: OutputBundle, - asset: OutputAsset, - bytes: Uint8Array, - renames: Map -): void { - const extension = posix.extname(asset.fileName) - const nextName = `assets/${sha256(bytes)}${extension}` - renameBundleItem(bundle, asset.fileName, nextName, asset, renames) -} - -function renameContentAddressedChunk( - bundle: OutputBundle, - chunk: OutputChunk, - renames: Map -): void { - const nextName = `assets/${sha256(chunk.code)}.js` - renameBundleItem(bundle, chunk.fileName, nextName, chunk, renames) -} - -function renameBundleItem( - bundle: OutputBundle, - oldName: string, - nextName: string, - item: OutputAsset | OutputChunk, - renames: Map -): void { - if (bundle[nextName]) { - throw new Error(`Duplicate mobile web content-addressed path: ${nextName}`) - } - delete bundle[oldName] - item.fileName = nextName - bundle[nextName] = item - renames.set(oldName, nextName) -} - -function replaceAssetReferences(source: string, renames: ReadonlyMap): string { - let output = source - for (const [oldName, nextName] of renames) { - output = output.replaceAll(oldName, nextName) - output = output.replaceAll(posix.basename(oldName), posix.basename(nextName)) - } - return output -} - -function manifestAssets(bundle: OutputBundle): MobileWebAsset[] { - return Object.values(bundle) - .filter((item) => item.fileName !== 'manifest.json') - .map((item) => { - const extension = posix.extname(item.fileName) as keyof typeof CONTENT_TYPE_BY_EXTENSION - const contentType = CONTENT_TYPE_BY_EXTENSION[extension] - const role = ROLE_BY_EXTENSION[extension] - if (!contentType || !role) { - throw new Error(`Unsupported mobile web asset type: ${item.fileName}`) - } - const bytes = item.type === 'chunk' ? new TextEncoder().encode(item.code) : assetBytes(item) - return { - path: item.fileName, - sha256: sha256(bytes), - byteLength: bytes.byteLength, - contentType, - role - } - }) - .sort((left, right) => left.path.localeCompare(right.path)) -} - -function assetBytes(asset: OutputAsset): Uint8Array { - return typeof asset.source === 'string' - ? new TextEncoder().encode(asset.source) - : new Uint8Array(asset.source) -} - -function sha256(value: string | Uint8Array): string { - return createHash('sha256').update(value).digest('hex') -} diff --git a/config/build-plugins/mobile-web-import-boundary.test.ts b/config/build-plugins/mobile-web-import-boundary.test.ts deleted file mode 100644 index b36afc2dd52..00000000000 --- a/config/build-plugins/mobile-web-import-boundary.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { resolve } from 'node:path' -import { describe, expect, it } from 'vitest' -import { mobileWebImportBoundaryViolation } from './mobile-web-import-boundary' - -const MOBILE_IMPORTER = resolve('src/mobile-web/src/mobile-web-shell.tsx') -const CARD_IMPORTER = resolve('src/renderer/src/components/ui/card.tsx') - -describe('mobile web import boundary', () => { - it.each([ - ['./mobile-web-shell', MOBILE_IMPORTER], - ['../../shared/mobile-web/bridge-contract', MOBILE_IMPORTER], - ['@renderer/components/ui/card', MOBILE_IMPORTER], - ['@/lib/utils', CARD_IMPORTER], - ['react', MOBILE_IMPORTER] - ])('allows approved import %s', (source, importer) => { - expect(mobileWebImportBoundaryViolation(source, importer)).toBeNull() - }) - - it.each([ - ['electron', MOBILE_IMPORTER], - ['node:fs', MOBILE_IMPORTER], - ['@electron-toolkit/preload', MOBILE_IMPORTER], - ['../../main/index', MOBILE_IMPORTER], - ['../../preload/index', MOBILE_IMPORTER], - ['@renderer/App', MOBILE_IMPORTER], - ['@/store', CARD_IMPORTER] - ])('rejects desktop or native import %s', (source, importer) => { - expect(mobileWebImportBoundaryViolation(source, importer)).not.toBeNull() - }) -}) diff --git a/config/build-plugins/mobile-web-import-boundary.ts b/config/build-plugins/mobile-web-import-boundary.ts deleted file mode 100644 index 2439468bb1c..00000000000 --- a/config/build-plugins/mobile-web-import-boundary.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { isAbsolute, relative, resolve } from 'node:path' -import type { Plugin } from 'vite' - -const MOBILE_WEB_ROOT = resolve('src/mobile-web') -const SHARED_ROOT = resolve('src/shared') -const SOURCE_ROOT = resolve('src') -const APPROVED_RENDERER_ROOTS = [ - resolve('src/renderer/src/assets'), - resolve('src/renderer/src/components/ui'), - resolve('src/renderer/src/lib/utils.ts') -] - -export function createMobileWebImportBoundaryPlugin(): Plugin { - return { - name: 'orca-mobile-web-import-boundary', - enforce: 'pre', - resolveId(source, importer) { - const violation = mobileWebImportBoundaryViolation(source, importer) - if (violation) { - this.error(violation) - } - return null - } - } -} - -export function mobileWebImportBoundaryViolation( - source: string, - importer: string | undefined -): string | null { - if (!importer || importer.includes('node_modules')) { - return null - } - if (source === 'electron' || source.startsWith('node:') || source.startsWith('@electron')) { - return `Mobile web code cannot import ${source}` - } - const resolved = resolveSourcePath(source, importer) - if (!resolved || isInside(resolved, MOBILE_WEB_ROOT) || isInside(resolved, SHARED_ROOT)) { - return null - } - if (APPROVED_RENDERER_ROOTS.some((root) => isApprovedRendererImport(resolved, root))) { - return null - } - return isInside(resolved, SOURCE_ROOT) - ? `Mobile web import crosses its approved boundary: ${source}` - : null -} - -function isApprovedRendererImport(candidate: string, root: string): boolean { - return isInside(candidate, root) || candidate === root.replace(/\.(?:ts|tsx)$/, '') -} - -function resolveSourcePath(source: string, importer: string): string | null { - if (source.startsWith('@renderer/')) { - return resolve('src/renderer/src', source.slice('@renderer/'.length)) - } - if (source.startsWith('@/')) { - return resolve('src/renderer/src', source.slice(2)) - } - if (source.startsWith('.')) { - return resolve(importer, '..', source) - } - return null -} - -function isInside(candidate: string, root: string): boolean { - const child = relative(root, candidate) - return child === '' || (!child.startsWith('..') && !isAbsolute(child)) -} diff --git a/config/build-plugins/mobile-web-style-boundary.ts b/config/build-plugins/mobile-web-style-boundary.ts deleted file mode 100644 index 8790e5a6aff..00000000000 --- a/config/build-plugins/mobile-web-style-boundary.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { readFile } from 'node:fs/promises' -import { resolve } from 'node:path' -import type { Plugin } from 'vite' - -const CANONICAL_STYLE_PATH = resolve('src/renderer/src/assets/main.css') -const MOBILE_WEB_RENDERER_STYLE_SOURCES = [ - "@source '../components/ui/badge.tsx';", - "@source '../components/ui/button.tsx';", - "@source '../components/ui/card.tsx';", - "@source '../components/ui/dialog-foundation.tsx';", - "@source '../components/ui/input.tsx';", - "@source '../components/ui/select.tsx';", - "@source '../components/ui/tabs.tsx';" -] -const DESKTOP_ONLY_IMPORTS = [ - "@import '@xterm/xterm/css/xterm.css';", - "@import 'katex/dist/katex.min.css';", - "@import './rich-markdown-editor.css';", - "@import './markdown-preview.css';", - "@import './terminal.css';", - "@import './mobile-page.css';" -] -const DESKTOP_SYMBOL_FONT = - /\n@font-face \{\n font-family: 'Orca Nerd Font Symbols';[\s\S]*?\n\}\n/ - -export function createMobileWebStyleBoundaryPlugin(): Plugin { - return { - name: 'orca-mobile-web-style-boundary', - enforce: 'pre', - async load(id) { - if (id.split('?')[0] !== CANONICAL_STYLE_PATH) { - return null - } - const source = await readFile(CANONICAL_STYLE_PATH, 'utf8') - const withoutDesktopImports = source - .split('\n') - .filter((line) => !DESKTOP_ONLY_IMPORTS.includes(line)) - .join('\n') - const withApprovedSources = withoutDesktopImports.replace( - "@import 'tailwindcss';", - ["@import 'tailwindcss';", ...MOBILE_WEB_RENDERER_STYLE_SOURCES].join('\n') - ) - if (withApprovedSources === withoutDesktopImports) { - this.error('Canonical styles no longer contain the expected Tailwind import') - } - const mobileStyles = withApprovedSources.replace(DESKTOP_SYMBOL_FONT, '\n') - if (mobileStyles === withApprovedSources) { - this.error('Canonical styles no longer contain the expected desktop symbol font block') - } - return mobileStyles - } - } -} diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 5b369a49fd7..b5fef34e153 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -724,7 +724,7 @@ async function createMobileWebResourceFixture(resourcesDir) { "frame-ancestors 'none'" ].join(';') const document = Buffer.from( - ``, + ``, 'utf8' ) const assets = [ diff --git a/config/scripts/verify-mobile-web-build.mjs b/config/scripts/verify-mobile-web-build.mjs deleted file mode 100644 index f8dcbda8f00..00000000000 --- a/config/scripts/verify-mobile-web-build.mjs +++ /dev/null @@ -1,139 +0,0 @@ -import { createHash } from 'node:crypto' -import { readFile, readdir } from 'node:fs/promises' -import path from 'node:path' -import { gzipSync } from 'node:zlib' -import { - MobileWebManifestSchema, - serializeMobileWebManifestForBuildId -} from '../../src/shared/mobile-web/manifest-contract.ts' - -const outputRoot = path.resolve('out/mobile-web') -const maxUncompressedBytes = 2 * 1024 * 1024 -const maxCompressedBytes = 512 * 1024 -const maxScriptBytes = 1024 * 1024 -const maxStyleBytes = 256 * 1024 -const manifestPath = path.join(outputRoot, 'manifest.json') -const manifest = MobileWebManifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8'))) - -const expectedBuildId = sha256(serializeMobileWebManifestForBuildId(manifest)) -if (manifest.buildId !== expectedBuildId) { - throw new Error('Mobile web manifest buildId does not match canonical manifest content') -} - -const assetBytes = new Map() -for (const asset of manifest.assets) { - const bytes = await readFile(path.join(outputRoot, ...asset.path.split('/'))) - assetBytes.set(asset.path, bytes) - if (bytes.byteLength !== asset.byteLength) { - throw new Error(`Mobile web asset length mismatch: ${asset.path}`) - } - if (sha256(bytes) !== asset.sha256) { - throw new Error(`Mobile web asset hash mismatch: ${asset.path}`) - } -} - -const scriptBytes = manifest.assets - .filter((asset) => asset.role === 'script') - .reduce((total, asset) => total + asset.byteLength, 0) -const styleBytes = manifest.assets - .filter((asset) => asset.role === 'style') - .reduce((total, asset) => total + asset.byteLength, 0) -const compressedBytes = manifest.assets - .map((asset) => gzipSync(assetBytes.get(asset.path))) - .reduce((total, bytes) => total + bytes.byteLength, 0) -if ( - manifest.totalBytes > maxUncompressedBytes || - compressedBytes > maxCompressedBytes || - scriptBytes > maxScriptBytes || - styleBytes > maxStyleBytes -) { - throw new Error( - `Mobile web bundle exceeds budget: total=${manifest.totalBytes}, compressed=${compressedBytes}, script=${scriptBytes}, style=${styleBytes}` - ) -} - -const actualPaths = (await listFiles(outputRoot)).filter((file) => file !== 'manifest.json').sort() -const declaredPaths = manifest.assets.map((asset) => asset.path) -if (JSON.stringify(actualPaths) !== JSON.stringify(declaredPaths)) { - throw new Error('Mobile web output contains undeclared or missing assets') -} - -const html = await readFile(path.join(outputRoot, 'index.html'), 'utf8') -const requiredCsp = [ - "default-src 'none'", - "script-src 'self'", - "style-src 'self'", - "img-src 'self' data: blob:", - "connect-src 'none'", - "object-src 'none'", - "base-uri 'none'", - "form-action 'none'", - "frame-src 'none'" -] -for (const directive of requiredCsp) { - if (!html.includes(directive)) { - throw new Error(`Mobile web CSP is missing: ${directive}`) - } -} - -for (const match of html.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)) { - const reference = match[1] - if (!reference?.startsWith('./assets/')) { - throw new Error(`Mobile web document contains a non-relative asset reference: ${reference}`) - } - if (!declaredPaths.includes(reference.slice(2))) { - throw new Error(`Mobile web document references an undeclared asset: ${reference}`) - } -} - -for (const asset of manifest.assets.filter((candidate) => candidate.role === 'script')) { - const source = await readFile(path.join(outputRoot, ...asset.path.split('/')), 'utf8') - if (/\beval\s*\(|\bnew\s+Function\s*\(/.test(source)) { - throw new Error(`Mobile web executable asset contains runtime code generation: ${asset.path}`) - } - if (/sourceMappingURL/.test(source)) { - throw new Error(`Mobile web executable asset contains a source map reference: ${asset.path}`) - } -} - -const styles = manifest.assets - .filter((asset) => asset.role === 'style') - .map((asset) => assetBytes.get(asset.path).toString('utf8')) - .join('\n') -const requiredDialogStyles = [ - /\.fixed\{position:fixed\}/, - /\.inset-0\{inset:calc\(var\(--spacing\)\s*\*\s*0\)\}/, - /\.top-\\\[50\\%\\\]\{top:50%\}/, - /\.left-\\\[50\\%\\\]\{left:50%\}/, - /\.translate-x-\\\[-50\\%\\\]\{[^}]*--tw-translate-x:-50%/, - /\.translate-y-\\\[-50\\%\\\]\{[^}]*--tw-translate-y:-50%/, - /\.max-w-\\\[calc\\\(100\\%-2rem\\\)\\\]\{max-width:calc\(100% - 2rem\)\}/, - /\.z-50\{z-index:50\}/ -] -for (const requiredStyle of requiredDialogStyles) { - if (!requiredStyle.test(styles)) { - throw new Error(`Mobile web styles are missing dialog utility: ${requiredStyle}`) - } -} - -console.log( - `Mobile web build verified: ${manifest.assets.length} assets, ${manifest.totalBytes} bytes (${compressedBytes} compressed), build ${manifest.buildId}` -) - -async function listFiles(root, relative = '') { - const entries = await readdir(path.join(root, relative), { withFileTypes: true }) - const files = [] - for (const entry of entries) { - const child = relative ? path.join(relative, entry.name) : entry.name - if (entry.isDirectory()) { - files.push(...(await listFiles(root, child))) - } else { - files.push(child.split(path.sep).join('/')) - } - } - return files -} - -function sha256(value) { - return createHash('sha256').update(value).digest('hex') -} diff --git a/config/tsconfig.mobile-web.json b/config/tsconfig.mobile-web.json index a474ec87708..bb79a10747a 100644 --- a/config/tsconfig.mobile-web.json +++ b/config/tsconfig.mobile-web.json @@ -2,14 +2,6 @@ "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", "include": [ "../src/mobile-web/src/**/*", - "../src/renderer/src/components/ui/badge.tsx", - "../src/renderer/src/components/ui/button.tsx", - "../src/renderer/src/components/ui/card.tsx", - "../src/renderer/src/components/ui/dialog-foundation.tsx", - "../src/renderer/src/components/ui/input.tsx", - "../src/renderer/src/components/ui/select.tsx", - "../src/renderer/src/components/ui/tabs.tsx", - "../src/renderer/src/lib/utils.ts", "../src/shared/agent-status-limits.ts", "../src/shared/clipboard-text.ts", "../src/shared/event-loop-yield.ts", @@ -23,11 +15,6 @@ "compilerOptions": { "composite": true, "jsx": "react-jsx", - "types": ["vite/client"], - "paths": { - "@mobile-web/*": ["../src/mobile-web/src/*"], - "@renderer/*": ["../src/renderer/src/*"], - "@/*": ["../src/renderer/src/*"] - } + "types": [] } } diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md index 2d10d43314a..2d860cb0f67 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md @@ -71,7 +71,7 @@ At the end of every implementation session: | ---------------------------------- | ----------- | --------: | ------------------------------------------------------ | | Prototype evidence | Complete | 8/8 | Simulator-only bounded vertical slice | | Contracts and parity inventory | Complete | 10/10 | Route, RPC, native, state, and failure ledgers frozen | -| Mobile web build | In progress | 9/12 | Packaged macOS/SSH lookup verified | +| Mobile web build | In progress | 10/12 | Duplicate validation UI removed; packaging gates open | | Package delivery RPC | In progress | 9/10 | Production names and RPC pass focused validation | | Native asset origin and cache | In progress | 7/15 | Cross-platform unit faults pass; device drills remain | | Capability bridge | In progress | 10/17 | Replay/race hardening passes; binary matrix remains | @@ -83,7 +83,7 @@ At the end of every implementation session: | Security and adversarial review | In progress | 1/15 | Executable isolation passes; broader review open | | Device and topology validation | In progress | 0/18 | Direct iOS and Android emulators pass core journeys | | App Store validation | Not started | 0/10 | Production submission, not TestFlight | -| Cutover and cleanup | Not started | 0/12 | Last implementation workstream | +| Cutover and cleanup | In progress | 1/12 | Duplicate validation UI removed | | Release evidence and documentation | Not started | 0/10 | Required before merge | **Current workstream:** Complete remaining parity, cutover cleanup, and the @@ -99,7 +99,8 @@ a second platform boundary: native retains those authorities, while the hosted page receives shell-owned identity and no page persistence. Leaving a host, reconnect, pairing repair, and paired-host removal now use a third narrow shell-operations boundary without changing rendered JSX. The purpose-built web -screens remain infrastructure fixtures and cannot be the cutover UI. The +screens and Vite-only package path are removed; only production page clients +and transport state remain under `src/mobile-web/`. The existing `NewWorktreeModalController`, `NewWorktreeModal`, Smart source drawer, and their view models now consume a named `HostWorkspaceCreationOperations` domain interface. Native maps that interface @@ -535,14 +536,14 @@ recovery, or physical-device gates. RPC returned build `c24ff987…`, then the actual iOS WKWebView rendered the unchanged Docker SSH workspace, mutated its remote terminal, and recovered native chat across SSH disconnect/reconnect. -- [~] Replace the Vite validation presentation with the React Native Web build - and remove equivalent `src/mobile-web/` UI components. The existing - mobile UI now packages deterministically through the production asset - format. `build:mobile-web`, development lookup, and Electron packaging now - select the RNW artifact by default; the Vite presentation remains only as an - explicitly named infrastructure fixture until its tests migrate. Cutover is - still blocked on full route parity, device evidence, and distribution - packaging. +- [x] Replace the Vite validation presentation with the React Native Web build + and remove equivalent `src/mobile-web/` UI components. The existing mobile + UI packages deterministically through the production asset format. + `build:mobile-web`, development lookup, and Electron packaging select the + RNW artifact. The retired Vite entry, package plugin/verifier, duplicate + workspace/session/files/source-control UI, and UI-only tests are removed. + Production bridge clients and transport tests remain. The rebuilt package + retains build `b17ead7a…`. ## 3. Production Package Delivery RPC @@ -1676,9 +1677,10 @@ copy. - [ ] Remove the Experimental Settings entry and prototype route. - [ ] Remove prototype contracts, package generator, RPC names, cache, bridge, and test fixtures superseded by production implementations. -- [ ] Remove the parallel `src/mobile-web/` presentation after parity and App - Store gates pass; retain the shared React Native component source rendered - through React Native Web. +- [x] Remove the parallel `src/mobile-web/` presentation while retaining its + production bridge clients and the shared React Native component source + rendered through React Native Web. The native workspace fallback remains + untouched until the App Store and release gates pass. - [ ] Keep native pairing, recovery, permissions, settings, and diagnostics. - [~] Drill automatic rollback from a crash-looping staged package. The exact Pixel 9 Pro API 36 Debug app crashed three distinct Chromium renderer @@ -2620,4 +2622,8 @@ and diff hygiene pass. A fresh production RNW build remains | 2026-07-28 | Finding | Native manifest parsing enforced asset count and field bounds only after parsing both supplied JSON documents. Swift and Kotlin now reject either raw manifest above 256 KiB before handing it to `JSONSerialization` or `JSONObject`. | | 2026-07-28 | Complete | Mirrored oversized primary/canonical manifest regressions pass the Swift fault executable and the refreshed Android module suite across 76 Gradle tasks. No staging directory is created for the rejected Android inputs. | | 2026-07-28 | Complete | The RNW packager, build verifier, iOS response policy, and Android response policy now share one exact document-CSP contract. Focused packager/native-source tests pass, and a fresh production RNW build retains build `9ed8c7f7…`, 49 assets, 9,280,463 raw bytes, and 2,684,481 gzip bytes. | -| 2026-07-28 | Next | Complete the remaining parity inventory and cutover cleanup, then execute the physical-device, topology, security, performance, packaged-release, and App Store gates. | +| 2026-07-28 | Finding | Persisted native primary/canonical manifests, activation metadata, and generation assets used whole-file APIs before their post-read bounds. Both stores now read only each limit plus one overflow byte and preserve stable staging, generation, and activation failures. | +| 2026-07-28 | Complete | Mirrored oversized persisted-file faults pass the Swift executable and Android 76-task module suite. Shared contracts pass 24 files / 203 tests; native source parity passes 1 file / 4 tests; all relevant typechecks, lints, formatting, max-lines, and diff hygiene pass. | +| 2026-07-28 | Complete | The obsolete standalone renderer-based mobile-web presentation, its Vite-only package path, and 12,621 lines of duplicate UI/tests are removed. Production bridge clients and transport state remain, and a boundary test prevents renderer UI from returning. The authoritative RNW package remains `b17ead7a…`: 49 assets, 9,281,663 raw bytes, and 2,684,764 gzip bytes. | +| 2026-07-28 | Complete | Post-removal validation passes 568 mobile files / 3,375 tests with 2 expected skips and 3,752 root files / 39,218 tests with 62 expected skips. Root/mobile/mobile-web lint, node/mobile/mobile-web typechecks, reliability, localization, max-lines, changed-file formatting, package verification, and diff hygiene pass. The packaged-resource fixture now matches the required safe-area viewport contract. | +| 2026-07-28 | Next | Complete compatibility release policy and the remaining gated cutover cleanup, then execute the physical-device, topology, security, performance, packaged-release, and App Store gates. | diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md index 1d5087b885a..ac712fe8bef 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md @@ -30,8 +30,9 @@ not satisfy parity. - Existing React Native screen, component, style, and view-model source is shared by the native and web runtimes. Target ownership identifies where the feature executes, not permission to reimplement its presentation. -- `src/mobile-web/` presentation components are temporary validation fixtures - and must be removed as shared mobile screens replace them. +- The obsolete `src/mobile-web/` validation presentation is removed. Its + remaining modules are production bridge clients and transport state used by + the shared React Native Web routes, not a second product UI. - A native settings route may configure a web-owned feature when the setting controls native input, permission, lifecycle, or recovery behavior. - Compatibility redirects disappear when all supported deep links route through @@ -521,6 +522,11 @@ activation records use a 1 KiB bounded reader, and assets use their declared length on both native platforms. Each path reads only one overflow byte before failing with its stable error, and mirrored Swift/Kotlin oversized-file faults pass. +The standalone renderer-based workspace, session, files, terminal, +source-control, and review presentation is also removed with its Vite-only +package path. A production-source boundary requires `src/mobile-web/` to remain +renderer-independent, while the authoritative RNW package still verifies as +build `b17ead7a…`. ## Next Inventory Action diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md index 0d5a0723eff..8e0ced64266 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md @@ -66,9 +66,10 @@ it reaches Desktop authority; it does not authorize a redesign. review any intentional product UI change separately against both runtimes. - Keep the native workspace route as the behavior oracle and working fallback until the final parity, security, device, and App Store gates pass. -- The current `src/mobile-web/` presentation is a security and transport - validation harness. Its workspace, session, file, diff, source-control, and - review components must not become the production UI. +- The former `src/mobile-web/` validation presentation is removed. Its + surviving modules are production bridge clients and transport state; the + workspace, session, file, diff, source-control, and review UI comes only from + the shared React Native source. - Automated screenshot comparisons and interaction fixtures against the existing mobile routes are merge gates. Functional parity without visual and interaction parity is insufficient. @@ -181,7 +182,8 @@ prototype: activate, and close controls. - Those purpose-built screens are a validation harness only. They prove the bridge operations and package lifecycle but do not satisfy the UI preservation - contract and are not eligible for production cutover. + contract and are not eligible for production cutover. They were retired after + the authoritative React Native Web route graph replaced their evidence. - Direct, Relay, and stable logical RPC clients expose a typed `terminal.multiplex` transport. The native broker resolves only workspace and tab IDs; terminal handles, cwd, credentials, connection identity, and input @@ -591,9 +593,9 @@ crossing that boundary. Build `abd43c62…` packages that shared route as 49 content-addressed assets, 7,201,072 bytes raw and 1,537,245 bytes with gzip. This is a production, minified Metro bundle, not a debug artifact. The earlier 2 MiB / 512 KiB -ceiling remains appropriate for the purpose-built infrastructure fixture, but -it cannot hold the complete authoritative mobile route graph without removing -features or reintroducing a parallel UI. That checkpoint introduced an 8 MiB +ceiling measured the now-retired infrastructure fixture; it could not hold the +complete authoritative mobile route graph without removing features or +reintroducing a parallel UI. That checkpoint introduced an 8 MiB total / 2 MiB gzip / 7.5 MiB script RNW ceiling. The later locally bundled Mermaid engine required the reviewed current ceiling of 10 MiB total, 3 MiB gzip, 9.5 MiB of scripts, 256 KiB of styles, and 64 assets. The verifier @@ -1286,13 +1288,11 @@ workspace scoping, input leases, provider checks, or filesystem boundaries. Use the dedicated host-only Expo Router entry to export the existing mobile presentation through React Native Web, then post-process it into `out/mobile-web-rnw/` as part of every desktop build and release. The separate -Vite entry under `src/mobile-web/` remains an infrastructure fixture until its -bridge/package tests have migrated and the shared presentation passes cutover. +Vite validation entry, content-addressing plugin, verifier, and duplicate +presentation have been removed after their bridge/package coverage migrated. Development fallback, `build:mobile-web`, and Electron's macOS/Linux/Windows -resource mapping now select `out/mobile-web-rnw/` without an environment -override. `ORCA_MOBILE_WEB_PACKAGE_ROOT` remains an explicit diagnostic/test -override. The Vite build is available only as `build:mobile-web-fixture`; it -must not become the runtime or release presentation. +resource mapping select `out/mobile-web-rnw/` without an environment override. +`ORCA_MOBILE_WEB_PACKAGE_ROOT` remains an explicit diagnostic/test override. Electron `afterPack` now runs the production package verifier against the copied `/mobile-web` tree, so a missing, corrupt, non-content- addressed, over-budget, or CSP-invalid resource fails packaging rather than @@ -2108,8 +2108,8 @@ measurement above its ceiling. The current 49-asset package is 9,281,663 bytes / 2,684,764 bytes gzip and build `b17ead7a3c85071f5cfc45dd695bd457e37a49c4895ad3ac979689ca2a13805f`. `build:mobile-web` and release resource mapping therefore select the RNW -package; the original 2 MiB ceiling continues to govern only the isolated Vite -infrastructure fixture. Workspace snapshots now page through +package; the original 2 MiB Vite-fixture ceiling is retired with that fixture. +Workspace snapshots now page through opaque single-use cursors: each page is capped at 200 rows and 120 KiB, the stable native-only source snapshot is capped at 10,000 rows and 8 MiB, and lifecycle cleanup revokes every @@ -2351,9 +2351,9 @@ config/ mobile-web-package.ts deterministic Expo Web package post-processing ``` -The existing `src/mobile-web/` DOM presentation is a temporary infrastructure -validation harness. Remove it when its bridge consumers have moved behind the -shared React Native presentation adapters. +The `src/mobile-web/` DOM presentation and Vite-only package path are removed. +The remaining directory contains production bridge clients and transport state +consumed by the shared React Native presentation adapters. Production names must drop `prototype`. Do not leave production behavior split between the experimental screen and a second implementation. @@ -2434,7 +2434,8 @@ feature UI. the shared source and rerun the parity fixtures; do not preserve an older forked snapshot for the web runtime. - Remove the purpose-built `src/mobile-web/` presentation as each shared screen - reaches parity; keep only the web entry, bridge client, and runtime adapters. + reaches parity. This is complete: only bridge clients and runtime transport + adapters remain, while the host-only Expo Router entry owns the web route. ### 7. Adapt workspace and session surfaces @@ -2578,9 +2579,9 @@ silent WebKit miss. ### 12. Cut over and remove duplicate workspace UI - Make the hybrid workspace route the default only after every gate passes. -- Remove the experimental entry and the parallel `src/mobile-web/` - presentation. Retain the shared React Native screen/component source used by - React Native Web; remove only superseded route/runtime adapters. +- Remove the experimental entry at cutover. The parallel `src/mobile-web/` + presentation is already removed; retain its production bridge clients and the + shared React Native screen/component source used by React Native Web. - Keep native pairing, recovery, permissions, settings, and diagnostics. - Build the final exact release candidate, rerun smoke/performance/security checks, and resubmit if the binary materially differs from the accepted diff --git a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md index 40bd896dfc2..599fd605fec 100644 --- a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md +++ b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md @@ -181,6 +181,17 @@ activation metadata with a 1 KiB ceiling, and assets with their exact declared length plus one overflow byte. Oversized files fail with the existing stable generation or activation error before whole-file allocation; mirrored Swift and Kotlin fault suites pass. +The obsolete standalone `src/mobile-web/` presentation and Vite-only package +path are removed. The directory now contains only production bridge clients, +transport state, and focused tests consumed by the real React Native Web route +graph. A source boundary prevents the duplicate renderer-based UI from +returning, and the production package remains build `b17ead7a…`. +Post-removal validation passes 568 mobile files / 3,375 tests with 2 expected +skips and 3,752 root files / 39,218 tests with 62 expected skips. Root, mobile, +and mobile-web lint; node, mobile, and mobile-web typechecks; reliability, +localization, max-lines, formatting, package verification, and diff hygiene +pass. The packaged-resource fixture now includes the same required safe-area +viewport contract as the production document. The remaining security work below is release-app corpus testing, fuzzing, cross-scope races, privacy/authorization audit, and independent review. @@ -197,9 +208,6 @@ cross-scope races, privacy/authorization audit, and independent review. - [ ] Remove the Experimental Settings entry and `hybrid-prototype` route. - [ ] Remove superseded prototype contracts, package generation, RPC names, cache, bridge code, and fixtures. -- [ ] Remove the purpose-built `src/mobile-web/` validation presentation while - retaining the shared React Native components rendered through React - Native Web. - [ ] Confirm production source and imports contain no `prototype` names. - [ ] Document Desktop web-package rollback and native store-rollout rollback. diff --git a/mobile/src/mobile-web-production-prototype-boundary.test.ts b/mobile/src/mobile-web-production-prototype-boundary.test.ts index f61d200868d..e4e46fe3472 100644 --- a/mobile/src/mobile-web-production-prototype-boundary.test.ts +++ b/mobile/src/mobile-web-production-prototype-boundary.test.ts @@ -1,4 +1,4 @@ -import { readFileSync, readdirSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync } from 'node:fs' import { extname, join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -19,6 +19,17 @@ const forbiddenProductionReferences = [ 'MobileWebPrototype', 'mobileWeb.prototype' ] +const standaloneClientRoot = new URL('../../src/mobile-web', import.meta.url) +const retiredStandaloneArtifacts = [ + new URL('../../src/mobile-web/index.html', import.meta.url), + new URL('../../src/mobile-web/src/entry.tsx', import.meta.url), + new URL('../../src/mobile-web/src/mobile-web-shell.tsx', import.meta.url), + new URL('../../vite.mobile-web.config.ts', import.meta.url), + new URL('../../config/scripts/verify-mobile-web-build.mjs', import.meta.url), + new URL('../../build-plugins/mobile-web-content-addressed.ts', import.meta.url), + new URL('../../build-plugins/mobile-web-import-boundary.ts', import.meta.url), + new URL('../../build-plugins/mobile-web-style-boundary.ts', import.meta.url) +] function sourceFiles(root: URL): URL[] { if (extname(root.pathname)) { @@ -44,4 +55,14 @@ describe('mobile web production prototype boundary', () => { expect(violations).toEqual([]) }) + + it('keeps the retired standalone presentation out of production', () => { + expect(retiredStandaloneArtifacts.filter((artifact) => existsSync(artifact))).toEqual([]) + const rendererImports = sourceFiles(standaloneClientRoot).flatMap((file) => { + const source = readFileSync(file, 'utf8') + return source.includes('@renderer') || source.includes('src/renderer') ? [file.pathname] : [] + }) + + expect(rendererImports).toEqual([]) + }) }) diff --git a/package.json b/package.json index 66285c4259f..b41d144caaf 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "doctor": "pnpm dlx react-doctor@0.9.1 . --no-telemetry", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", - "lint:mobile-web": "oxlint src/mobile-web config/build-plugins/mobile-web-content-addressed.ts config/build-plugins/mobile-web-import-boundary.ts config/build-plugins/mobile-web-import-boundary.test.ts config/build-plugins/mobile-web-style-boundary.ts vite.mobile-web.config.ts config/scripts/verify-mobile-web-build.mjs config/scripts/verify-mobile-web-rnw-build.mjs", + "lint:mobile-web": "oxlint src/mobile-web config/scripts/verify-mobile-web-rnw-build.mjs", "prepare": "husky", "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", @@ -54,7 +54,6 @@ "dev": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs", "dev-stable-name": "pnpm run ensure:electron-runtime && node config/scripts/run-electron-vite-dev.mjs --stable-name", "dev:web": "vite --config vite.web.config.ts --host 127.0.0.1", - "dev:mobile-web": "vite --config vite.mobile-web.config.ts --host 127.0.0.1", "build:relay": "node config/scripts/build-relay.mjs", "build:computer-macos": "node config/scripts/build-computer-macos.mjs", "build:notification-status-macos": "node config/scripts/build-notification-status-macos.mjs", @@ -75,7 +74,6 @@ "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", "build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs", "build:mobile-web": "pnpm run build:mobile-web-rnw", - "build:mobile-web-fixture": "vite build --config vite.mobile-web.config.ts && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/verify-mobile-web-build.mjs", "build:mobile-web-rnw": "pnpm --dir mobile export:host-web -- ../out/mobile-web-rnw-export && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/package-mobile-web-rnw.mjs && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/verify-mobile-web-rnw-build.mjs", "build:desktop": "pnpm run typecheck && pnpm run typecheck:mobile-web && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run verify:cli-runtime && pnpm run build:web-from-renderer && pnpm run build:mobile-web", "build": "pnpm run build:desktop && pnpm run build:native", diff --git a/src/mobile-web/index.html b/src/mobile-web/index.html deleted file mode 100644 index 1736f0ed7d9..00000000000 --- a/src/mobile-web/index.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - Orca Workspace - - -
- - - diff --git a/src/mobile-web/src/entry.tsx b/src/mobile-web/src/entry.tsx deleted file mode 100644 index c40e0099e73..00000000000 --- a/src/mobile-web/src/entry.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import '@renderer/assets/main.css' -import '@xterm/xterm/css/xterm.css' -import { MobileWebShell } from './mobile-web-shell' - -document.documentElement.classList.add('dark') - -const root = document.getElementById('root') - -if (!root) { - throw new Error('Mobile web root is missing') -} - -ReactDOM.createRoot(root).render( - - - -) diff --git a/src/mobile-web/src/mobile-web-file-directory.tsx b/src/mobile-web/src/mobile-web-file-directory.tsx deleted file mode 100644 index ba21758ed51..00000000000 --- a/src/mobile-web/src/mobile-web-file-directory.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { Button } from '@renderer/components/ui/button' -import { ChevronRight, File, FileSymlink, Folder, Loader2 } from 'lucide-react' -import React from 'react' -import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' -import { joinMobileWebFilePath, mobileWebFileBreadcrumbs } from './mobile-web-file-path' -import type { MobileWebDirectoryView } from './use-mobile-web-file-directory' - -export function MobileWebFileDirectory({ - directory, - connected, - onNavigate, - onOpenFile, - onRetry -}: { - directory: MobileWebDirectoryView - connected: boolean - onNavigate: (relativePath: string) => void - onOpenFile: (relativePath: string) => void - onRetry: () => void -}): React.JSX.Element { - const breadcrumbs = mobileWebFileBreadcrumbs(directory.relativePath) - return ( -
- - {directory.error ? ( -
- {directoryErrorCopy(directory.error)} - {directory.error.retryable ? ( - - ) : null} -
- ) : null} - -
- ) -} - -function DirectoryEntries({ - directory, - connected, - onNavigate, - onOpenFile -}: { - directory: MobileWebDirectoryView - connected: boolean - onNavigate: (relativePath: string) => void - onOpenFile: (relativePath: string) => void -}): React.JSX.Element | null { - const result = directory.result - if (!result) { - return null - } - if (result.entries.length === 0) { - return ( -

- This folder is empty. -

- ) - } - return ( - <> -
    - {result.entries.map((entry) => { - const relativePath = joinMobileWebFilePath(result.relativePath, entry.name) - return ( -
  • - -
  • - ) - })} -
- {result.truncated ? ( -

- This folder exceeds the 128-entry mobile listing limit. -

- ) : null} - - ) -} - -function directoryErrorCopy(error: MobileWebBridgeClientError): string { - if (error.code === 'unsupported_capability') { - return 'This Orca Mobile shell cannot browse folders.' - } - if (error.code === 'not_connected') { - return 'Reconnect to the paired desktop to browse folders.' - } - return 'The paired desktop could not provide this folder.' -} diff --git a/src/mobile-web/src/mobile-web-file-document.test.ts b/src/mobile-web/src/mobile-web-file-document.test.ts deleted file mode 100644 index 6759165f440..00000000000 --- a/src/mobile-web/src/mobile-web-file-document.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES, - appendMobileWebFileChunk, - mobileWebFileNextChunkLength -} from './mobile-web-file-document' - -describe('mobile web file document', () => { - it('assembles ordered chunks without corrupting split UTF-8 characters', () => { - const first = appendMobileWebFileChunk(null, { - workspaceId: 'workspace-1', - relativePath: 'notes.txt', - offset: 0, - bytes: Uint8Array.from([0x41, 0xe2, 0x82]), - bytesRead: 3, - eof: false - }) - expect(first.content).toBe('A') - - const complete = appendMobileWebFileChunk(first, { - workspaceId: 'workspace-1', - relativePath: 'notes.txt', - offset: 3, - bytes: Uint8Array.from([0xac, 0x42]), - bytesRead: 2, - eof: true - }) - expect(complete.content).toBe('A€B') - expect(complete.kind).toBe('text') - expect(complete.eof).toBe(true) - }) - - it('rejects reordered, empty non-final, and cross-file chunks', () => { - const initial = appendMobileWebFileChunk(null, { - workspaceId: 'workspace-1', - relativePath: 'notes.txt', - offset: 0, - bytes: Uint8Array.from([65]), - bytesRead: 1, - eof: false - }) - expect(() => - appendMobileWebFileChunk(initial, { - workspaceId: 'workspace-1', - relativePath: 'other.txt', - offset: 1, - bytes: new Uint8Array(), - bytesRead: 0, - eof: false - }) - ).toThrow() - expect(() => - appendMobileWebFileChunk(initial, { - workspaceId: 'workspace-1', - relativePath: 'notes.txt', - offset: 0, - bytes: Uint8Array.from([66]), - bytesRead: 1, - eof: true - }) - ).toThrow() - }) - - it('detects binary content and enforces the page document cap', () => { - const binary = appendMobileWebFileChunk(null, { - workspaceId: 'workspace-1', - relativePath: 'image.bin', - offset: 0, - bytes: Uint8Array.from([65, 0, 66]), - bytesRead: 3, - eof: true - }) - expect(binary.kind).toBe('binary') - - const nearLimit = { - workspaceId: 'workspace-1', - relativePath: 'large.txt', - bytes: new Uint8Array(MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES - 1), - content: '', - kind: 'text' as const, - eof: false, - limitReached: false, - revision: null - } - expect(mobileWebFileNextChunkLength(nearLimit)).toBe(1) - const capped = appendMobileWebFileChunk(nearLimit, { - workspaceId: 'workspace-1', - relativePath: 'large.txt', - offset: MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES - 1, - bytes: Uint8Array.from([65]), - bytesRead: 1, - eof: false - }) - expect(capped.limitReached).toBe(true) - expect(mobileWebFileNextChunkLength(capped)).toBe(0) - }) - - it('supports a separate bounded assembly limit for raster images', () => { - const maximum = 4 - const initial = appendMobileWebFileChunk( - null, - { - workspaceId: 'workspace-1', - relativePath: 'image.png', - offset: 0, - bytes: Uint8Array.from([0x89, 0x50]), - bytesRead: 2, - eof: false - }, - maximum - ) - expect(mobileWebFileNextChunkLength(initial, maximum)).toBe(2) - - const capped = appendMobileWebFileChunk( - initial, - { - workspaceId: 'workspace-1', - relativePath: 'image.png', - offset: 2, - bytes: Uint8Array.from([0x4e, 0x47]), - bytesRead: 2, - eof: false - }, - maximum - ) - expect(capped.limitReached).toBe(true) - expect(mobileWebFileNextChunkLength(capped, maximum)).toBe(0) - }) -}) diff --git a/src/mobile-web/src/mobile-web-file-document.ts b/src/mobile-web/src/mobile-web-file-document.ts deleted file mode 100644 index 79d22231d35..00000000000 --- a/src/mobile-web/src/mobile-web-file-document.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { - MOBILE_WEB_FILE_CHUNK_MAX_BYTES, - type MobileWebFileChunkResult -} from '../../shared/mobile-web/bridge-operation-contract' -import { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' -import { mobileWebFileRevision } from './mobile-web-file-edit-content' - -export const MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES = 1024 * 1024 - -export type MobileWebFileDocument = { - workspaceId: string - relativePath: string - bytes: Uint8Array - content: string - kind: 'text' | 'binary' - eof: boolean - limitReached: boolean - revision: string | null -} - -export function mobileWebFileNextChunkLength( - document: MobileWebFileDocument | null, - maximumBytes = MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES -): number { - const remaining = maximumBytes - (document?.bytes.byteLength ?? 0) - return Math.min(MOBILE_WEB_FILE_CHUNK_MAX_BYTES, remaining) -} - -export function appendMobileWebFileChunk( - document: MobileWebFileDocument | null, - chunk: MobileWebFileChunkResult, - maximumBytes = MOBILE_WEB_FILE_DOCUMENT_MAX_BYTES -): MobileWebFileDocument { - const expectedOffset = document?.bytes.byteLength ?? 0 - if ( - chunk.offset !== expectedOffset || - (document && - (document.workspaceId !== chunk.workspaceId || - document.relativePath !== chunk.relativePath)) || - (chunk.bytesRead === 0 && !chunk.eof) || - chunk.bytesRead > maximumBytes - expectedOffset - ) { - throw new MobileWebBridgeClientError('invalid_message', false) - } - - const bytes = new Uint8Array(expectedOffset + chunk.bytesRead) - if (document) { - bytes.set(document.bytes) - } - bytes.set(chunk.bytes, expectedOffset) - const limitReached = bytes.byteLength >= maximumBytes && !chunk.eof - const decoded = decodeMobileWebText(bytes, chunk.eof) - return { - workspaceId: chunk.workspaceId, - relativePath: chunk.relativePath, - bytes, - content: decoded ?? '', - kind: document?.kind === 'binary' || decoded === null ? 'binary' : 'text', - eof: chunk.eof, - limitReached, - revision: chunk.eof && decoded !== null ? mobileWebFileRevision(bytes) : null - } -} - -function decodeMobileWebText(bytes: Uint8Array, eof: boolean): string | null { - if (bytes.includes(0)) { - return null - } - try { - return new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: !eof }) - } catch { - return null - } -} diff --git a/src/mobile-web/src/mobile-web-file-editor.tsx b/src/mobile-web/src/mobile-web-file-editor.tsx deleted file mode 100644 index ea23d4e3db6..00000000000 --- a/src/mobile-web/src/mobile-web-file-editor.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Button } from '@renderer/components/ui/button' -import { Loader2 } from 'lucide-react' -import React, { useEffect, useState } from 'react' -import { MOBILE_WEB_FILE_EDIT_MAX_BYTES } from '../../shared/mobile-web/file-edit-contract' -import type { MobileWebFileEditorState } from './use-mobile-web-file-editor' - -export function MobileWebFileEditor({ - editor, - connected, - onChange, - onSave, - onCancel -}: { - editor: Exclude - connected: boolean - onChange: (value: string) => void - onSave: () => void - onCancel: () => void -}): React.JSX.Element { - const [showSaving, setShowSaving] = useState(false) - const byteLength = new TextEncoder().encode(editor.draft).byteLength - const tooLarge = byteLength > MOBILE_WEB_FILE_EDIT_MAX_BYTES - const unchanged = editor.draft === editor.initialContent - - useEffect(() => { - if (editor.status !== 'saving') { - setShowSaving(false) - return - } - const timer = window.setTimeout(() => setShowSaving(true), 200) - return () => window.clearTimeout(timer) - }, [editor.status]) - - return ( -
-