diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d4497f807..357f4c8d1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -22,3 +22,38 @@ jobs: run: pnpm build - name: Run Tests run: pnpm test + + chat-runtime: + name: Chat PostgreSQL runtime + runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: tanchat_test + POSTGRES_PASSWORD: tanchat_test + POSTGRES_DB: tanchat_test_ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U tanchat_test -d tanchat_test_ci" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql://tanchat_test:tanchat_test@127.0.0.1:5432/tanchat_test_ci + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Setup Tools + uses: tanstack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main + - name: Create existing-site test boundary + run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f harness-tests/postgres/site-baseline.sql + - name: Migrate disposable database + run: pnpm db:migrate + - name: Check cascade lifecycle + run: psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f harness-tests/postgres/cascade-lifecycle.sql + - name: Run conversation runtime tests + run: pnpm test:chat-runtime diff --git a/.gitignore b/.gitignore index fd17d4c80..9e5a97423 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ test-results src/routeTree.gen.ts .og-preview/ .readme-preview/ + +# Generated native desktop helper +/desktop/native/folder-access diff --git a/.oxlintrc.json b/.oxlintrc.json index f6467d88c..02c991cb8 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -199,6 +199,26 @@ "react/exhaustive-deps": "warn" }, "plugins": ["react", "jsx-a11y"] + }, + { + "files": ["desktop/preload.cjs"], + "rules": { + "typescript/no-require-imports": "off" + } + }, + { + "files": ["**/SavedFiles.tsx"], + "plugins": ["react", "jsx-a11y"], + "rules": { + "jsx-a11y/no-noninteractive-tabindex": [ + "error", + { + "tags": [], + "roles": ["tabpanel", "region"], + "allowExpressionValues": true + } + ] + } } ] } diff --git a/README.md b/README.md index f168f491e..0fb857be7 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,11 @@ To edit docs for a project, make changes in its `docs/` folder (e.g., `../form/d … and more at TanStack.com » + +### TanChat alpha access + +TanChat is invite-only, independent of Builder access. Signing in to TanStack does not unlock it. Admins and TanStack maintainers with a linked GitHub account have automatic access and unlimited invites. Maintainer identity comes from the verified GitHub account ID and the site's maintainer catalog, not an editable profile name. + +Other users unlock TanChat by redeeming a single-use invite and receive three invites to share. Issuing a link consumes one invite, even if the link expires unused. Links expire after seven days. Account settings links to `/chat-access`, where users can create and copy invites. The main site menu links to TanChat, and accounts without access see the invite page. + +Migration `0039_tanchat_invites` adds grants and hashed invite tokens. Server functions and chat HTTP endpoints enforce access independently of the UI. Quota checks lock the issuer account, and redemption locks the invite while granting access and consuming it in one transaction. The local app uses the shared configured database, so invite grants also persist for future production releases. diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 000000000..57ddfc969 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,21 @@ +# TanStack desktop + +This is the Electron host ported from Gum. It opens `https://tanstack.com/chat` in packaged builds and retains the native browser, folder grants, connected-device transport, and update controller. + +Install dependencies from the repository root with `pnpm install`. + +For development, start the website, then run: + +```sh +TANSTACK_APP_URL=http://127.0.0.1:3000 pnpm dev:desktop +``` + +Use the origin and port of your running website. The development command installs the declared Electron binary using Electron’s own `install-electron` command. Packaged builds ignore development URL overrides. + +`pnpm test:desktop` builds the native folder helper and runs the desktop tests. These also run in the repository’s normal test command. + +`pnpm pack:desktop` creates a local app directory in `dist/desktop`. Electron Builder may use an available signing identity. This command does not publish an update. + +For a distributable macOS build, configure `TANSTACK_UPDATE_URL` with the HTTPS update directory and provide Apple notarization credentials through `APPLE_KEYCHAIN_PROFILE`, an Apple API key, or the existing Apple ID credential variables. Run `pnpm dist:desktop`. Upload the generated artifacts and update metadata to that same directory after verifying signing and notarization. The command does not publish automatically. + +The original release scripts are retained in scripts/desktop-release.mjs and scripts/desktop-publish.mjs. `pnpm verify:desktop-release` checks signing, notarization, artifacts, and update metadata without publishing. `pnpm finalize:desktop-release` can submit notarization and regenerate hashes and blockmaps. `pnpm publish:desktop-release` verifies first, then publishes to the explicitly configured TANSTACK_RELEASE_BUCKET and TANSTACK_UPDATE_URL. The update URL must expose that bucket at its configured directory. Provisioning and publishing still require release approval. diff --git a/desktop/assets/README.md b/desktop/assets/README.md new file mode 100644 index 000000000..f8c50e0de --- /dev/null +++ b/desktop/assets/README.md @@ -0,0 +1,5 @@ +# Desktop artwork + +The TanStack desktop app uses the existing square TanStack logo at `public/images/logos/logo-color-600.png`. Electron Builder converts that PNG into the platform icon format. + +The Kody images in this directory are historical source assets from the Gum desktop port. They are not used by the TanStack package. The original koala artwork came from https://github.com/kentcdodds/kody/blob/main/packages/worker/public/logo.png. The squircle and square files were edited variants. diff --git a/desktop/assets/kody-icon.png b/desktop/assets/kody-icon.png new file mode 100644 index 000000000..47544d37e Binary files /dev/null and b/desktop/assets/kody-icon.png differ diff --git a/desktop/assets/kody-square.png b/desktop/assets/kody-square.png new file mode 100644 index 000000000..e704e473c Binary files /dev/null and b/desktop/assets/kody-square.png differ diff --git a/desktop/assets/kody-squircle.png b/desktop/assets/kody-squircle.png new file mode 100644 index 000000000..0fb85993e Binary files /dev/null and b/desktop/assets/kody-squircle.png differ diff --git a/desktop/build-device-helper.mjs b/desktop/build-device-helper.mjs new file mode 100644 index 000000000..92bb01ade --- /dev/null +++ b/desktop/build-device-helper.mjs @@ -0,0 +1,18 @@ +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { mkdirSync } from 'node:fs' +const directory = fileURLToPath(new URL('./native/', import.meta.url)) +mkdirSync(directory, { recursive: true }) +execFileSync( + 'cc', + [ + '-O2', + '-Wall', + '-Wextra', + '-Werror', + directory + 'folder-access.c', + '-o', + directory + 'folder-access', + ], + { stdio: 'inherit' }, +) diff --git a/desktop/builder.config.mjs b/desktop/builder.config.mjs new file mode 100644 index 000000000..34a240b06 --- /dev/null +++ b/desktop/builder.config.mjs @@ -0,0 +1,67 @@ +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const updateUrl = process.env.TANSTACK_UPDATE_URL +if (updateUrl) { + const url = new URL(updateUrl) + if ( + url.protocol !== 'https:' || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error( + 'TANSTACK_UPDATE_URL must be an HTTPS directory URL without credentials or query parameters.', + ) + } +} + +export default { + beforePack: ({ packager }) => { + execFileSync( + process.execPath, + [fileURLToPath(new URL('./build-device-helper.mjs', import.meta.url))], + { stdio: 'inherit' }, + ) + if (!packager.config.forceCodeSigning) return + if (!updateUrl) + throw new Error( + 'Set TANSTACK_UPDATE_URL to the hosted native update directory before making a release.', + ) + const env = process.env + const notarization = + env.APPLE_KEYCHAIN_PROFILE || + (env.APPLE_API_KEY && env.APPLE_API_KEY_ID && env.APPLE_API_ISSUER) || + (env.APPLE_ID && env.APPLE_APP_SPECIFIC_PASSWORD && env.APPLE_TEAM_ID) + if (!notarization) + throw new Error( + 'Configure Apple notarization credentials before making a release.', + ) + }, + appId: 'com.tanstack.desktop', + productName: 'TanStack', + asar: true, + asarUnpack: ['native/folder-access'], + forceCodeSigning: true, + directories: { output: '../dist/desktop' }, + files: [ + '*.mjs', + '!*.test.mjs', + '!builder.config.mjs', + 'preload.cjs', + 'package.json', + 'native/folder-access', + ], + extraMetadata: { nativeUpdates: Boolean(updateUrl) }, + publish: updateUrl ? [{ provider: 'generic', url: updateUrl }] : null, + mac: { + icon: '../public/images/logos/logo-color-600.png', + category: 'public.app-category.productivity', + target: ['dmg', 'zip'], + hardenedRuntime: true, + notarize: true, + }, + artifactName: 'TanStack-${version}-${arch}.${ext}', + dmg: { sign: true }, +} diff --git a/desktop/connected-device.mjs b/desktop/connected-device.mjs new file mode 100644 index 000000000..c3863a0b3 --- /dev/null +++ b/desktop/connected-device.mjs @@ -0,0 +1,245 @@ +import { hostname } from 'node:os' +import { join, basename } from 'node:path' +import { readFile, writeFile, rename, realpath, stat } from 'node:fs/promises' +import { randomUUID } from 'node:crypto' +import { readGrantedFolder } from './device-folders.mjs' + +export async function createConnectedDevice({ + app, + safeStorage, + dialog, + session, + origin, +}) { + const path = join(app.getPath('userData'), 'connected-device.json') + let state = null, + stopped = false, + timer, + error = '', + working = false, + authenticated = false, + syncing = null, + saving = Promise.resolve() + async function save() { + if (!safeStorage.isEncryptionAvailable()) + throw new Error('Secure credential storage is unavailable.') + const encrypted = safeStorage + .encryptString(JSON.stringify(state)) + .toString('base64') + saving = saving + .catch(() => {}) + .then(async () => { + await writeFile(path + '.tmp', encrypted, { mode: 0o600 }) + await rename(path + '.tmp', path) + }) + await saving + } + try { + state = JSON.parse( + safeStorage.decryptString( + Buffer.from(await readFile(path, 'utf8'), 'base64'), + ), + ) + if (state?.origin !== origin) state = null + } catch (e) { + if (e.code !== 'ENOENT') error = 'Reconnect this device to restore access.' + } + async function account(body) { + const response = await session.fetch(origin + '/api/chat/account/devices', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: origin }, + body: JSON.stringify(body), + redirect: 'error', + }) + if (!response.ok) throw new Error('Sign in to connect this device.') + return response.json() + } + async function syncAccount(enable = false) { + if (syncing) await syncing + const run = async () => { + const response = await session.fetch( + origin + '/api/chat/account/devices', + { + redirect: 'error', + }, + ) + authenticated = false + if (response.status === 401) return false + if (!response.ok) throw new Error('Could not check device sign-in.') + const userId = response.headers.get('X-Device-Account') + if (!userId) + throw new Error( + 'Update the server to enable automatic device registration.', + ) + const devices = await response.json() + if (state?.userId !== userId) { + state = null + await save() + } + if (state && !devices.some((device) => device.id === state.id)) { + state.revoked = true + state.grants = [] + await save() + } + if (state?.revoked && !enable) { + authenticated = true + return false + } + if (!state || state.revoked) { + if (process.platform !== 'darwin') return false + if (!safeStorage.isEncryptionAvailable()) + throw new Error('Secure credential storage is unavailable.') + const registration = await account({ + type: 'register', + name: hostname(), + }) + if (registration.userId !== userId) + throw new Error('Account changed. Please try again.') + state = { ...registration, name: hostname(), origin, grants: [] } + await save() + } + authenticated = true + return true + } + syncing = run() + try { + return await syncing + } finally { + syncing = null + } + } + async function assertAccount(expectedId) { + if (!(await syncAccount()) || state?.id !== expectedId) + throw new Error('Account or device access changed. Please try again.') + } + async function transport(body) { + const response = await fetch(origin + '/api/chat/device-transport', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + state.token, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10000), + redirect: 'error', + }) + if (response.status === 401) { + state.revoked = true + state.grants = [] + await save() + throw new Error('Device access was revoked.') + } + if (!response.ok) throw new Error('Device connection is unavailable.') + return response.json() + } + async function poll() { + if (stopped || working) return + working = true + try { + if (await syncAccount()) { + const deviceId = state.id + const request = await transport({ + type: 'poll', + grants: state.grants.map(({ id, name }) => ({ id, name })), + }) + error = '' + if (request && Date.now() < request.expiresAt) { + await assertAccount(deviceId) + let result + try { + result = await readGrantedFolder(state.grants, request) + } catch { + result = { + error: + 'The file could not be read. Check the path and folder permission.', + } + } + if ( + state && + !stopped && + state.grants.some((g) => g.id === request.grantId) + ) { + await assertAccount(deviceId) + await transport({ + type: 'result', + id: request.id, + result: JSON.stringify(result), + }) + } + } + } + } catch (e) { + error = e.message + } finally { + working = false + if (!stopped) timer = setTimeout(poll, 2000) + } + } + void poll() + return { + status: () => ({ + connected: authenticated && !!state && !state.revoked, + name: state?.name ?? hostname(), + folders: !authenticated + ? [] + : (state?.grants.map(({ id, name }) => ({ id, name })) ?? []), + error, + }), + async connect() { + await syncAccount() + }, + async shareFolder() { + if (!(await syncAccount(true))) + throw new Error('Sign in to share a folder.') + const deviceId = state.id + const selection = await dialog.showOpenDialog({ + title: 'Share a folder with TanStack', + properties: ['openDirectory'], + }) + if (selection.canceled) return + const approval = await dialog.showMessageBox({ + type: 'question', + buttons: ['Cancel', 'Share folder'], + defaultId: 0, + cancelId: 0, + message: 'Allow TanStack to read files in this folder?', + detail: + 'File contents may be sent to your private cloud conversations and their model provider. Access stays available while the desktop app is running. You can remove access in Devices.', + }) + if (approval.response !== 1) return + await assertAccount(deviceId) + if (state.grants.length >= 20) + throw new Error('Remove a folder before sharing another.') + const path = await realpath(selection.filePaths[0]) + const info = await stat(path) + state.grants.push({ + id: randomUUID(), + name: basename(path), + path, + dev: info.dev, + ino: info.ino, + }) + await save() + }, + async removeFolder(id) { + if (state) { + await assertAccount(state.id) + state.grants = state.grants.filter((g) => g.id !== id) + await save() + } + }, + async disconnect() { + if (state) { + await assertAccount(state.id) + await account({ type: 'revoke', id: state.id }) + state.revoked = true + state.grants = [] + await save() + } + }, + stop() { + stopped = true + clearTimeout(timer) + }, + } +} diff --git a/desktop/connected-device.test.mjs b/desktop/connected-device.test.mjs new file mode 100644 index 000000000..e88b2396f --- /dev/null +++ b/desktop/connected-device.test.mjs @@ -0,0 +1,102 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createConnectedDevice } from './connected-device.mjs' + +test( + 'macOS sign-in registers without grants, respects revocation, and isolates account changes', + { skip: process.platform !== 'darwin' }, + async () => { + const path = await mkdtemp(join(tmpdir(), 'banks-autoreg-')) + let user = 'a', + registrations = 0, + devices = [] + const originalFetch = globalThis.fetch + globalThis.fetch = async () => Response.json(null) + const session = { + fetch: async (_url, options = {}) => { + if (!user) return new Response('', { status: 401 }) + if (options.method === 'POST') { + const body = JSON.parse(options.body) + if (body.type === 'register') { + const id = String(++registrations) + devices.push({ id, user }) + return Response.json({ id, userId: user, token: 'test' }) + } + } + return Response.json( + devices.filter((d) => d.user === user), + { headers: { 'X-Device-Account': user } }, + ) + }, + } + const host = await createConnectedDevice({ + app: { getPath: () => path }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (s) => Buffer.from(s), + decryptString: (b) => b.toString(), + }, + dialog: {}, + session, + origin: 'https://banks.test', + }) + try { + await host.connect() + assert.equal(registrations, 1) + assert.deepEqual(host.status().folders, []) + assert.equal(host.status().connected, true) + devices = [] + await host.connect() + assert.equal(registrations, 1) + assert.equal(host.status().connected, false) + user = null + await host.connect() + assert.equal(host.status().connected, false) + user = 'b' + await host.connect() + assert.equal(registrations, 2) + assert.deepEqual(host.status().folders, []) + } finally { + host.stop() + globalThis.fetch = originalFetch + await rm(path, { recursive: true, force: true }) + } + }, +) + +test( + 'other platforms do not automatically register devices', + { skip: process.platform === 'darwin' }, + async () => { + const path = await mkdtemp(join(tmpdir(), 'banks-no-autoreg-')) + let registrations = 0 + const host = await createConnectedDevice({ + app: { getPath: () => path }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (value) => Buffer.from(value), + decryptString: (value) => value.toString(), + }, + dialog: {}, + session: { + fetch: async (_url, options = {}) => { + if (options.method === 'POST') registrations++ + return Response.json([], { headers: { 'X-Device-Account': 'a' } }) + }, + }, + origin: 'https://banks.test', + }) + try { + await host.connect() + assert.equal(registrations, 0) + assert.equal(host.status().connected, false) + assert.deepEqual(host.status().folders, []) + } finally { + host.stop() + await rm(path, { recursive: true, force: true }) + } + }, +) diff --git a/desktop/device-folders.mjs b/desktop/device-folders.mjs new file mode 100644 index 000000000..04d8e10f9 --- /dev/null +++ b/desktop/device-folders.mjs @@ -0,0 +1,42 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { fileURLToPath } from 'node:url' +const execute = promisify(execFile) +const helper = fileURLToPath( + new URL('./native/folder-access', import.meta.url), +).replace('app.asar/', 'app.asar.unpacked/') +export async function readGrantedFolder(grants, request) { + const grant = grants.find((g) => g.id === request.grantId) + if (!grant) throw new Error('Folder permission was removed.') + if ( + !['list', 'read'].includes(request.operation) || + typeof request.path !== 'string' || + request.path.includes('\0') + ) + throw new Error('Invalid operation.') + const { stdout } = await execute( + helper, + [ + grant.path, + String(grant.dev), + String(grant.ino), + request.path, + request.operation, + ], + { encoding: 'buffer', maxBuffer: 131072, timeout: 5000 }, + ) + if (request.operation === 'list') { + const entries = stdout.toString('utf8').split('\0').filter(Boolean) + return { + entries: entries.slice(0, 200).map((e) => ({ + name: e.slice(1), + kind: + e[0] === 'd' ? 'directory' : e[0] === 'f' ? 'file' : 'unavailable', + })), + limited: entries.length > 200, + } + } + const text = new TextDecoder('utf-8', { fatal: true }).decode(stdout) + if (text.includes('\0')) throw new Error('Binary files are not supported.') + return { text } +} diff --git a/desktop/device-folders.test.mjs b/desktop/device-folders.test.mjs new file mode 100644 index 000000000..c2923e517 --- /dev/null +++ b/desktop/device-folders.test.mjs @@ -0,0 +1,59 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + mkdtemp, + realpath, + stat, + writeFile, + symlink, + rm, + mkdir, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { readGrantedFolder } from './device-folders.mjs' +test('folder grants read text, reject escapes and revoked access', async () => { + const path = await realpath(await mkdtemp(join(tmpdir(), 'banks-device-'))) + try { + await writeFile(join(path, 'note.txt'), 'A device note') + await symlink('/etc', join(path, 'escape')) + await mkdir(join(path, 'nested')) + await writeFile(join(path, 'nested', 'ok.txt'), 'nested') + const info = await stat(path), + grants = [{ id: 'folder', path, dev: info.dev, ino: info.ino }] + const request = { grantId: 'folder', operation: 'read', path: 'note.txt' } + assert.deepEqual(await readGrantedFolder(grants, request), { + text: 'A device note', + }) + assert.deepEqual( + await readGrantedFolder(grants, { ...request, path: 'nested/ok.txt' }), + { text: 'nested' }, + ) + assert.equal( + ( + await readGrantedFolder(grants, { + ...request, + operation: 'list', + path: '', + }) + ).entries.length, + 3, + ) + await assert.rejects( + readGrantedFolder(grants, { ...request, path: '../other' }), + ) + await assert.rejects( + readGrantedFolder(grants, { ...request, path: 'escape/passwd' }), + ) + await assert.rejects(readGrantedFolder([], request)) + await assert.rejects( + readGrantedFolder([{ ...grants[0], ino: -1 }], request), + ) + await writeFile(join(path, 'large.txt'), 'x'.repeat(65537)) + await assert.rejects( + readGrantedFolder(grants, { ...request, path: 'large.txt' }), + ) + } finally { + await rm(path, { recursive: true, force: true }) + } +}) diff --git a/desktop/external-links.mjs b/desktop/external-links.mjs new file mode 100644 index 000000000..c8fc1cda9 --- /dev/null +++ b/desktop/external-links.mjs @@ -0,0 +1,52 @@ +export function createAppWindowOpenHandler({ + appOrigin, + getCurrentUrl, + authWindowOptions, + openExternal, + openApp, + reportError, +}) { + return ({ url, frameName }) => { + try { + if (new URL(getCurrentUrl()).origin !== appOrigin) + return { action: 'deny' } + if ( + url === 'about:blank' && + /^kody-banks-auth-[a-f0-9-]{36}$/.test(frameName) + ) { + return { + action: 'allow', + overrideBrowserWindowOptions: authWindowOptions, + } + } + const target = new URL(url) + if ( + !['https:', 'http:'].includes(target.protocol) || + target.username || + target.password + ) { + return { action: 'deny' } + } + if ( + target.origin === appOrigin && + openApp && + !target.pathname.startsWith('/auth/') + ) { + openApp(target.href) + return { action: 'deny' } + } + // Only ordinary web URLs reach the OS, never file or custom app protocols. + void Promise.resolve() + .then(() => openExternal(target.href)) + .catch(() => { + reportError( + 'Could not open your browser. Copy the link and open it in your browser.', + target.href, + ) + }) + } catch { + return { action: 'deny' } + } + return { action: 'deny' } + } +} diff --git a/desktop/external-links.test.mjs b/desktop/external-links.test.mjs new file mode 100644 index 000000000..544b87bef --- /dev/null +++ b/desktop/external-links.test.mjs @@ -0,0 +1,84 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { setImmediate } from 'node:timers/promises' +import { createAppWindowOpenHandler } from './external-links.mjs' + +function setup(overrides = {}) { + const opened = [] + const errors = [] + const options = { webPreferences: { sandbox: true, nodeIntegration: false } } + const handler = createAppWindowOpenHandler({ + appOrigin: 'https://gum.example', + getCurrentUrl: () => 'https://gum.example/chat', + authWindowOptions: options, + openExternal: async (url) => opened.push(url), + reportError: (message) => errors.push(message), + ...overrides, + }) + return { handler, opened, errors, options } +} + +test('Open setup web links open in the system browser without creating an Electron window', async () => { + const { handler, opened } = setup() + assert.deepEqual( + handler({ + url: 'https://kody.codes/account/integrations?setup=google', + frameName: '_blank', + }), + { action: 'deny' }, + ) + await setImmediate() + assert.deepEqual(opened, [ + 'https://kody.codes/account/integrations?setup=google', + ]) +}) + +test('named sign-in popup still opens with its isolated options', async () => { + const { handler, opened, options } = setup() + assert.deepEqual( + handler({ + url: 'about:blank', + frameName: 'kody-banks-auth-12345678-1234-1234-1234-123456789012', + }), + { + action: 'allow', + overrideBrowserWindowOptions: options, + }, + ) + await setImmediate() + assert.deepEqual(opened, []) +}) + +test('unsafe protocols, credentials, malformed URLs, and untrusted pages cannot launch apps', async () => { + const { handler, opened } = setup() + for (const url of [ + 'file:///tmp/test', + 'javascript:alert(1)', + 'custom-app:run', + 'https://user:pass@example.com', + 'not a URL', + 'about:blank', + ]) { + assert.deepEqual(handler({ url, frameName: '_blank' }), { action: 'deny' }) + } + const untrusted = setup({ getCurrentUrl: () => 'https://other.example' }) + assert.deepEqual( + untrusted.handler({ url: 'https://kody.codes', frameName: '_blank' }), + { action: 'deny' }, + ) + await setImmediate() + assert.deepEqual(opened, []) + assert.deepEqual(untrusted.opened, []) +}) + +test('browser launch failures show a recovery message', async () => { + const { handler, errors } = setup({ + openExternal: async () => { + throw new Error('OS launch failed') + }, + }) + handler({ url: 'https://kody.codes', frameName: '_blank' }) + await setImmediate() + assert.equal(errors.length, 1) + assert.match(errors[0], /Could not open your browser/) +}) diff --git a/desktop/main.mjs b/desktop/main.mjs new file mode 100644 index 000000000..13034ef84 --- /dev/null +++ b/desktop/main.mjs @@ -0,0 +1,936 @@ +import { createConnectedDevice } from './connected-device.mjs' +import { + app, + BrowserWindow, + Menu, + WebContentsView, + clipboard, + dialog, + ipcMain, + shell, + safeStorage, + session, +} from 'electron' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { readFile, rename, writeFile } from 'node:fs/promises' +import { createHash, randomUUID } from 'node:crypto' +import { resolveAppUrl } from './runtime-config.mjs' +import { createNativeUpdater } from './updates.mjs' +import { createAppWindowOpenHandler } from './external-links.mjs' + +const directory = dirname(fileURLToPath(import.meta.url)) +app.setName('TanStack') +app.setPath('userData', join(app.getPath('appData'), 'TanStack')) +const appUrl = resolveAppUrl({ + packaged: app.isPackaged, + developmentUrl: process.env.TANSTACK_APP_URL, +}) + +const windows = new Map() +const registeredChannels = new Set() +const profiles = new Map() +let connectedDevice +let nativeUpdater +let appZoomPercent = 100 +let saveHistory = Promise.resolve() +let saveAppSettings = Promise.resolve() +const zoomSteps = [50, 67, 75, 80, 90, 100, 110, 125, 150, 175, 200, 250, 300] +const appSettingsPath = () => + join(app.getPath('userData'), 'desktop-settings.json') + +function nextZoom(current, direction) { + return direction === 0 + ? 100 + : direction > 0 + ? (zoomSteps.find((step) => step > current + 0.5) ?? 300) + : ([...zoomSteps].reverse().find((step) => step < current - 0.5) ?? 50) +} + +async function loadAppSettings() { + try { + const value = JSON.parse(await readFile(appSettingsPath(), 'utf8')) + if ( + Number.isFinite(value.zoomPercent) && + value.zoomPercent >= 50 && + value.zoomPercent <= 300 + ) { + appZoomPercent = value.zoomPercent + } + } catch (error) { + if (error?.code !== 'ENOENT') + console.error('Could not read desktop settings:', error) + } +} + +function setAppZoom(direction) { + appZoomPercent = nextZoom(appZoomPercent, direction) + for (const controller of windows.values()) controller.setZoom() + const path = appSettingsPath() + const contents = JSON.stringify({ zoomPercent: appZoomPercent }) + saveAppSettings = saveAppSettings + .then(async () => { + const temporary = `${path}.tmp` + await writeFile(temporary, contents, { mode: 0o600 }) + await rename(temporary, path) + }) + .catch((error) => console.error('Could not save desktop settings:', error)) +} + +function installApplicationMenu() { + Menu.setApplicationMenu( + Menu.buildFromTemplate([ + ...(process.platform === 'darwin' + ? [ + { + label: 'TanStack', + submenu: [ + { role: 'about', label: 'About TanStack' }, + { + label: 'Check for Updates…', + click: () => void nativeUpdater.checkWithDialog(), + }, + { type: 'separator' }, + { role: 'services' }, + { type: 'separator' }, + { role: 'hide', label: 'Hide TanStack' }, + { role: 'hideOthers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit', label: 'Quit TanStack' }, + ], + }, + ] + : []), + { + label: 'File', + submenu: [ + { + label: 'New Window', + accelerator: 'CommandOrControl+N', + click: () => createWindow(), + }, + { + label: 'Open Conversation in New Window', + accelerator: 'CommandOrControl+Shift+N', + click: () => + createWindow( + focusedWindow()?.webContents.getURL() || appUrl.href, + ), + }, + { role: 'close' }, + ], + }, + { role: 'editMenu' }, + { + label: 'View', + submenu: [ + { + label: 'Refresh TanStack', + accelerator: 'CommandOrControl+R', + click: () => { + focusedWindow()?.webContents.reloadIgnoringCache() + void nativeUpdater?.check() + }, + }, + { + label: 'Check for Updates…', + click: () => void nativeUpdater?.checkWithDialog(), + }, + { type: 'separator' }, + { + label: 'Zoom In TanStack', + accelerator: 'CommandOrControl+Plus', + click: () => setAppZoom(1), + }, + { + label: 'Zoom Out TanStack', + accelerator: 'CommandOrControl+-', + click: () => setAppZoom(-1), + }, + { + label: 'Reset TanStack Zoom', + accelerator: 'CommandOrControl+0', + click: () => setAppZoom(0), + }, + ], + }, + { role: 'windowMenu' }, + ]), + ) +} + +async function loadProfileData(profileId) { + const data = { history: [], downloads: [], extensions: [], zoomPercent: 100 } + try { + const value = JSON.parse( + await readFile( + join(app.getPath('userData'), `browser-${profileId}.json`), + 'utf8', + ), + ) + data.history = Array.isArray(value.history) + ? value.history + .filter( + (entry) => + typeof entry.url === 'string' && + typeof entry.title === 'string' && + Number.isFinite(entry.visitedAt), + ) + .slice(0, 1000) + : [] + data.downloads = Array.isArray(value.downloads) + ? value.downloads + .filter( + (entry) => + typeof entry.id === 'string' && + typeof entry.filename === 'string' && + typeof entry.path === 'string', + ) + .slice(0, 100) + : [] + data.extensions = Array.isArray(value.extensions) + ? value.extensions + .filter( + (entry) => + typeof entry.path === 'string' && + typeof entry.id === 'string' && + typeof entry.name === 'string', + ) + .slice(0, 30) + : [] + data.zoomPercent = + Number.isFinite(value.zoomPercent) && + value.zoomPercent >= 50 && + value.zoomPercent <= 300 + ? value.zoomPercent + : 100 + } catch (error) { + if (error?.code !== 'ENOENT') + console.error('Could not read browser history:', error) + } + return data +} + +function focusedWindow() { + const focused = BrowserWindow.getFocusedWindow() + return ( + [...windows.values()].find((entry) => entry.window === focused)?.window ?? + [...windows.values()].at(-1)?.window + ) +} +function assertAppSender(event) { + const controller = windows.get(event.sender.id) + if ( + !controller || + controller.window.isDestroyed() || + event.sender !== controller.window.webContents || + event.senderFrame !== event.sender.mainFrame || + new URL(event.senderFrame.url).origin !== appUrl.origin + ) { + throw new Error( + 'Desktop control is only available to the TanStack app frame.', + ) + } + return controller +} + +function createWindow(initialUrl = appUrl.href) { + const target = new URL(initialUrl) + if (target.origin !== appUrl.origin || target.username || target.password) + throw new Error('Invalid app window URL.') + let window + let closed = false + let browserView + let browserVisible = false + let browserProfile = '' + let profileData = { + history: [], + downloads: [], + extensions: [], + zoomPercent: 100, + } + let browserZoomPercent = 100 + let deviceEmulation = 'off' + let find = { query: '', active: 0, matches: 0 } + let profileChange = Promise.resolve() + const handlers = new Map() + function handle(channel, callback) { + handlers.set(channel, callback) + if (registeredChannels.has(channel)) return + registeredChannels.add(channel) + ipcMain.handle(channel, (event, ...args) => + assertAppSender(event).handlers.get(channel)(event, ...args), + ) + } + + function persistBrowserData(data = profileData, profile = browserProfile) { + const contents = JSON.stringify(data) + const path = join(app.getPath('userData'), `browser-${profile}.json`) + for (const controller of windows.values()) { + if (controller.profile() === profile) controller.publishState() + } + saveHistory = saveHistory + .then(async () => { + const temporary = `${path}.tmp` + await writeFile(temporary, contents, { mode: 0o600 }) + await rename(temporary, path) + }) + .catch((error) => console.error('Could not save browser history:', error)) + } + + function recordVisit( + url, + title, + data = profileData, + profile = browserProfile, + ) { + if (!url || !/^https?:\/\//.test(url)) return + const latest = data.history[0] + if (latest?.url === url && Date.now() - latest.visitedAt < 2000) { + latest.title = title || latest.title + return + } + data.history.unshift({ url, title: title || url, visitedAt: Date.now() }) + data.history = data.history.slice(0, 1000) + persistBrowserData(data, profile) + } + + function browserState() { + const contents = browserView?.webContents + return { + url: contents?.getURL() || '', + title: contents?.getTitle() || '', + canGoBack: contents?.navigationHistory.canGoBack() || false, + canGoForward: contents?.navigationHistory.canGoForward() || false, + loading: contents?.isLoading() || false, + zoomPercent: browserZoomPercent, + appZoomPercent, + deviceEmulation, + find, + downloads: profileData.downloads.slice(0, 20), + extensions: profileData.extensions.map( + ({ id, name, version, status }) => ({ id, name, version, status }), + ), + } + } + + function publishState() { + if (window && !window.isDestroyed()) { + window.webContents.send('gum:browser:state-changed', browserState()) + } + } + + function checkedPageUrl(value) { + if (typeof value !== 'string' || value.length > 4096) + throw new Error('Invalid URL.') + const parsed = new URL(value.includes('://') ? value : `https://${value}`) + if ( + parsed.username || + parsed.password || + (parsed.protocol !== 'https:' && + !( + parsed.protocol === 'http:' && + (parsed.hostname === '127.0.0.1' || parsed.hostname === 'localhost') + )) + ) { + throw new Error('Only HTTPS sites and local previews can open here.') + } + return parsed.href + } + + function setVisible(visible) { + if (!window || !browserView) return + if (browserVisible === visible) return + browserVisible = visible + if (visible) window.contentView.addChildView(browserView) + else window.contentView.removeChildView(browserView) + } + + function initializeWindow() { + window = new BrowserWindow({ + width: 1320, + height: 900, + minWidth: 760, + minHeight: 520, + title: 'TanStack', + ...(process.platform === 'darwin' + ? { titleBarStyle: 'hiddenInset' } + : {}), + webPreferences: { + preload: join(directory, 'preload.cjs'), + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }) + window.webContents.setZoomMode('isolated') + window.webContents.setZoomFactor(appZoomPercent / 100) + window.webContents.on('did-finish-load', () => { + if ( + Math.abs(window.webContents.getZoomFactor() * 100 - appZoomPercent) > + 0.5 + ) { + window.webContents.setZoomFactor(appZoomPercent / 100) + } + publishState() + }) + window.webContents.setWindowOpenHandler( + createAppWindowOpenHandler({ + appOrigin: appUrl.origin, + getCurrentUrl: () => window.webContents.getURL(), + openApp: (url) => createWindow(url), + openExternal: (url) => shell.openExternal(url), + reportError: (message, url) => { + const choice = dialog.showMessageBoxSync({ + type: 'error', + message, + buttons: ['Close', 'Copy link'], + defaultId: 1, + cancelId: 0, + }) + if (choice === 1) clipboard.writeText(url) + }, + authWindowOptions: { + width: 560, + height: 760, + title: 'Sign in to TanStack', + ...(process.platform === 'darwin' + ? { titleBarStyle: 'hiddenInset' } + : {}), + webPreferences: { + preload: join(directory, 'preload.cjs'), + additionalArguments: ['--kody-auth-window'], + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }, + }), + ) + window.webContents.on('did-create-window', (popup) => { + popup.webContents.setWindowOpenHandler(() => ({ action: 'deny' })) + const restrictNavigation = (event, target) => { + const url = new URL(target) + if (url.protocol !== 'https:' && url.origin !== appUrl.origin) + event.preventDefault() + } + popup.webContents.on('will-navigate', restrictNavigation) + popup.webContents.on('will-redirect', restrictNavigation) + }) + window.webContents.on('will-navigate', (event, url) => { + if (new URL(url).origin !== appUrl.origin) event.preventDefault() + }) + const windowId = window.webContents.id + window.on('closed', () => { + windows.delete(windowId) + closed = true + browserView?.webContents.close() + browserView = undefined + browserVisible = false + }) + windows.set(window.webContents.id, { + window, + handlers, + publishState, + profile: () => browserProfile, + setZoom: () => { + window.webContents.setZoomFactor(appZoomPercent / 100) + publishState() + }, + }) + void window.loadURL(initialUrl) + } + + async function createBrowserView() { + const data = profileData + const profile = browserProfile + browserView = new WebContentsView({ + webPreferences: { + partition: `persist:gum-browser-${browserProfile}`, + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + }, + }) + const guest = browserView.webContents + for (const entry of data.extensions) { + try { + const loaded = + guest.session.extensions + .getAllExtensions() + .find((item) => item.path === entry.path) ?? + (await guest.session.extensions.loadExtension(entry.path)) + entry.id = loaded.id + entry.name = loaded.name + entry.version = loaded.version + entry.status = 'loaded' + } catch (error) { + entry.status = `Could not load: ${String(error).slice(0, 200)}` + } + } + if (closed || guest.isDestroyed()) return + if (data.extensions.length) persistBrowserData(data, profile) + guest.setZoomMode('isolated') + guest.setZoomFactor(browserZoomPercent / 100) + guest.session.setPermissionRequestHandler( + (_contents, _permission, callback) => callback(false), + ) + guest.setWindowOpenHandler(() => ({ action: 'deny' })) + guest.on('will-navigate', (event, url) => { + try { + checkedPageUrl(url) + } catch { + event.preventDefault() + } + }) + guest.on('will-redirect', (event, details) => { + if (!details.isMainFrame) return + try { + checkedPageUrl(details.url) + } catch { + event.preventDefault() + } + }) + for (const name of [ + 'did-navigate', + 'did-navigate-in-page', + 'page-title-updated', + 'did-start-loading', + 'did-stop-loading', + ]) { + guest.on(name, publishState) + } + guest.on('did-navigate', (_event, url) => + recordVisit(url, guest.getTitle(), data, profile), + ) + guest.on('did-navigate-in-page', (_event, url, isMainFrame) => { + if (isMainFrame) recordVisit(url, guest.getTitle(), data, profile) + }) + guest.on('page-title-updated', (_event, title) => { + if (data.history[0]?.url === guest.getURL()) { + data.history[0].title = title + persistBrowserData(data, profile) + } + }) + guest.on('found-in-page', (_event, result) => { + find = { + ...find, + active: result.activeMatchOrdinal, + matches: result.matches, + } + publishState() + }) + guest.on('did-finish-load', () => { + if (Math.abs(guest.getZoomFactor() * 100 - browserZoomPercent) > 0.5) { + guest.setZoomFactor(browserZoomPercent / 100) + } + publishState() + }) + guest.on('zoom-changed', publishState) + guest.on('before-input-event', (event, input) => { + if ( + !(input.meta || input.control) || + input.alt || + input.type !== 'keyDown' + ) + return + const key = input.key.toLowerCase() + if (key === 'f' || key === 'l') { + event.preventDefault() + window?.webContents.send( + 'gum:browser:shortcut', + key === 'f' ? 'find' : 'address', + ) + } else if (key === 'p') { + event.preventDefault() + guest.print({ silent: false, printBackground: true }) + } else if (key === 'r') { + event.preventDefault() + window.webContents.reloadIgnoringCache() + void nativeUpdater?.check() + } + }) + const onDownload = (_event, item, source) => { + if (source !== guest) return + const entry = { + id: randomUUID(), + filename: item.getFilename(), + url: item.getURL(), + path: '', + receivedBytes: 0, + totalBytes: item.getTotalBytes(), + status: 'downloading', + } + item.setSaveDialogOptions({ + defaultPath: join(app.getPath('downloads'), entry.filename), + }) + data.downloads.unshift(entry) + data.downloads = data.downloads.slice(0, 100) + publishState() + item.on('updated', () => { + entry.receivedBytes = item.getReceivedBytes() + entry.totalBytes = item.getTotalBytes() + entry.path = item.getSavePath() + publishState() + }) + item.on('done', (_event, status) => { + entry.status = status + entry.path = item.getSavePath() + entry.receivedBytes = item.getReceivedBytes() + persistBrowserData(data, profile) + publishState() + }) + } + const guestSession = guest.session + guestSession.on('will-download', onDownload) + guest.on('destroyed', () => + guestSession.removeListener('will-download', onDownload), + ) + } + + handle('gum:browser:profile', (event, profileId) => { + assertAppSender(event) + if (typeof profileId !== 'string' || !profileId || profileId.length > 256) + throw new Error('Invalid browser profile.') + const next = createHash('sha256').update(profileId).digest('hex') + const operation = profileChange.then(async () => { + if (closed || (next === browserProfile && browserView)) return + setVisible(false) + browserView?.webContents.close() + browserView = undefined + browserProfile = next + if (!profiles.has(next)) profiles.set(next, loadProfileData(next)) + profileData = await profiles.get(next) + if (closed) return + browserZoomPercent = profileData.zoomPercent + find = { query: '', active: 0, matches: 0 } + deviceEmulation = 'off' + await createBrowserView() + if (!closed) publishState() + }) + profileChange = operation.catch(() => {}) + return operation + }) + handle('gum:browser:bounds', (event, bounds) => { + assertAppSender(event) + if ( + !bounds || + !['x', 'y', 'width', 'height'].every((key) => + Number.isFinite(bounds[key]), + ) + ) { + throw new Error('Invalid browser bounds.') + } + const area = window.contentView.getBounds() + const scale = window.webContents.getZoomFactor() + const left = Math.min( + area.x + area.width, + Math.max(area.x, Math.floor(bounds.x * scale)), + ) + const top = Math.min( + area.y + area.height, + Math.max(area.y, Math.floor(bounds.y * scale)), + ) + const right = Math.min( + area.x + area.width, + Math.ceil((bounds.x + bounds.width) * scale), + ) + const bottom = Math.min( + area.y + area.height, + Math.ceil((bounds.y + bounds.height) * scale), + ) + const box = { + x: left, + y: top, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + } + browserView.setBounds(box) + setVisible(box.width > 0 && box.height > 0) + }) + handle('gum:browser:hide', (event) => { + assertAppSender(event) + setVisible(false) + }) + handle('gum:browser:state', (event) => { + assertAppSender(event) + return browserState() + }) + handle('gum:browser:navigate', async (event, url) => { + assertAppSender(event) + await browserView.webContents.loadURL(checkedPageUrl(url)) + return browserState() + }) + handle('gum:browser:back', (event) => { + assertAppSender(event) + if (browserView.webContents.navigationHistory.canGoBack()) + browserView.webContents.navigationHistory.goBack() + }) + handle('gum:browser:forward', (event) => { + assertAppSender(event) + if (browserView.webContents.navigationHistory.canGoForward()) + browserView.webContents.navigationHistory.goForward() + }) + handle('gum:browser:reload', (event) => { + assertAppSender(event) + browserView.webContents.reload() + }) + handle('gum:browser:find', (event, query, next = false, forward = true) => { + assertAppSender(event) + if (typeof query !== 'string' || query.length > 500) + throw new Error('Invalid search text.') + if (!query) { + browserView.webContents.stopFindInPage('clearSelection') + find = { query: '', active: 0, matches: 0 } + publishState() + return + } + find = { ...find, query } + browserView.webContents.findInPage(query, { findNext: !next, forward }) + }) + handle('gum:browser:print', (event) => { + assertAppSender(event) + browserView.webContents.print({ silent: false, printBackground: true }) + }) + handle('gum:browser:zoom', (event, direction) => { + assertAppSender(event) + if (![-1, 0, 1].includes(direction)) + throw new Error('Invalid zoom direction.') + const contents = browserView.webContents + const current = contents.getZoomFactor() * 100 + const next = nextZoom(current, direction) + contents.setZoomFactor(next / 100) + browserZoomPercent = next + profileData.zoomPercent = next + persistBrowserData() + publishState() + return next + }) + handle('gum:browser:device', (event, preset) => { + assertAppSender(event) + if (!['off', 'phone', 'tablet'].includes(preset)) + throw new Error('Invalid device mode.') + deviceEmulation = preset + if (preset !== 'off') + browserView.webContents.enableDeviceEmulation({ + screenPosition: 'mobile', + screenSize: + preset === 'phone' + ? { width: 390, height: 844 } + : { width: 768, height: 1024 }, + viewSize: + preset === 'phone' + ? { width: 390, height: 844 } + : { width: 768, height: 1024 }, + deviceScaleFactor: preset === 'phone' ? 3 : 2, + }) + else browserView.webContents.disableDeviceEmulation() + publishState() + }) + handle('gum:browser:screenshot', async (event) => { + assertAppSender(event) + const currentView = browserView + const assertCurrentView = () => { + if ( + closed || + !currentView || + currentView !== browserView || + currentView.webContents.isDestroyed() + ) + throw new Error('This browser window changed. Try again.') + } + const image = await browserView.webContents.capturePage() + const result = await dialog.showSaveDialog(window, { + title: 'Save screenshot', + defaultPath: join(app.getPath('pictures'), 'TanStack browser.png'), + filters: [{ name: 'PNG image', extensions: ['png'] }], + }) + if (result.canceled || !result.filePath) return false + assertCurrentView() + await writeFile(result.filePath, image.toPNG()) + return true + }) + handle('gum:browser:history', (event) => { + assertAppSender(event) + return profileData.history.slice(0, 100) + }) + handle('gum:browser:open-download', async (event, id) => { + assertAppSender(event) + const entry = profileData.downloads.find((item) => item.id === id) + if (!entry || entry.status !== 'completed' || !entry.path) + throw new Error('Download is unavailable.') + return shell.openPath(entry.path) + }) + handle('gum:browser:clear-data', async (event) => { + assertAppSender(event) + const currentView = browserView + const assertCurrentView = () => { + if ( + closed || + !currentView || + currentView !== browserView || + currentView.webContents.isDestroyed() + ) + throw new Error('This browser window changed. Try again.') + } + const answer = await dialog.showMessageBox(window, { + type: 'warning', + buttons: ['Cancel', 'Clear browser data'], + defaultId: 0, + cancelId: 0, + message: 'Clear browser data?', + detail: + 'This signs you out of websites in TanStack and clears local browsing history and the downloads list. Downloaded files stay on your computer.', + }) + if (answer.response !== 1) return false + assertCurrentView() + await currentView.webContents.session.clearData() + assertCurrentView() + profileData.history = [] + profileData.downloads = [] + persistBrowserData() + publishState() + return true + }) + handle('gum:browser:add-extension', async (event) => { + assertAppSender(event) + const currentView = browserView + const assertCurrentView = () => { + if ( + closed || + !currentView || + currentView !== browserView || + currentView.webContents.isDestroyed() + ) + throw new Error('This browser window changed. Try again.') + } + const selection = await dialog.showOpenDialog(window, { + title: 'Choose an unpacked extension folder', + properties: ['openDirectory'], + }) + if (selection.canceled || !selection.filePaths[0]) return false + assertCurrentView() + const path = selection.filePaths[0] + const manifest = JSON.parse( + await readFile(join(path, 'manifest.json'), 'utf8'), + ) + if (typeof manifest.name !== 'string' || !manifest.name.trim()) { + throw new Error( + 'This folder does not contain a valid extension manifest.', + ) + } + const permissions = [ + ...(Array.isArray(manifest.permissions) ? manifest.permissions : []), + ...(Array.isArray(manifest.host_permissions) + ? manifest.host_permissions + : []), + ...(Array.isArray(manifest.content_scripts) + ? manifest.content_scripts.flatMap((script) => + Array.isArray(script.matches) ? script.matches : [], + ) + : []), + ].filter((value) => typeof value === 'string') + const permissionSummary = + permissions.length > 20 + ? `${permissions.slice(0, 20).join(', ')}, and ${permissions.length - 20} more` + : permissions.join(', ') + const answer = await dialog.showMessageBox(window, { + type: 'warning', + buttons: ['Cancel', 'Load extension'], + defaultId: 0, + cancelId: 0, + message: `Load ${manifest.name}?`, + detail: `Extensions can read and change pages in this browser profile. Requested permissions and site access: ${permissionSummary || 'none listed'}. This extension loads from the selected folder on every app start. Keep that folder in place.`, + }) + if (answer.response !== 1) return false + assertCurrentView() + if (profileData.extensions.some((entry) => entry.path === path)) { + throw new Error('This extension is already loaded.') + } + const loaded = + await currentView.webContents.session.extensions.loadExtension(path) + assertCurrentView() + profileData.extensions.push({ + path, + id: loaded.id, + name: loaded.name, + version: loaded.version, + status: 'loaded', + }) + persistBrowserData() + publishState() + return true + }) + handle('gum:browser:remove-extension', async (event, id) => { + assertAppSender(event) + const entry = profileData.extensions.find((item) => item.id === id) + if (!entry) throw new Error('Extension not found.') + browserView.webContents.session.extensions.removeExtension(id) + profileData.extensions = profileData.extensions.filter( + (item) => item !== entry, + ) + persistBrowserData() + publishState() + }) + initializeWindow() + return window +} +app.whenReady().then(async () => { + await loadAppSettings() + connectedDevice = await createConnectedDevice({ + app, + safeStorage, + dialog, + session: session.defaultSession, + origin: appUrl.origin, + }) + for (const method of [ + 'status', + 'connect', + 'shareFolder', + 'removeFolder', + 'disconnect', + ]) { + ipcMain.handle(`gum:device:${method}`, async (event, value) => { + assertAppSender(event) + if (method === 'removeFolder' && typeof value !== 'string') + throw new Error('Invalid folder.') + return connectedDevice[method](value) + }) + } + app.on('before-quit', () => connectedDevice.stop()) + nativeUpdater = await createNativeUpdater({ + app, + dialog, + getWindow: () => focusedWindow(), + prepareToQuit: () => {}, + }) + ipcMain.handle('gum:desktop:describe', (event) => { + assertAppSender(event) + return { + appVersion: app.getVersion(), + protocols: [1], + capabilities: ['browser.v1', 'updates.v1', 'devices.v1'], + } + }) + for (const action of ['state', 'check', 'download', 'install']) { + ipcMain.handle(`gum:desktop:updates:${action}`, (event) => { + assertAppSender(event) + return nativeUpdater[action]() + }) + } + installApplicationMenu() + createWindow() + app.on('activate', () => { + const window = focusedWindow() + if (window) { + window.show() + window.focus() + } else createWindow() + }) +}) + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit() +}) diff --git a/desktop/native/folder-access.c b/desktop/native/folder-access.c new file mode 100644 index 000000000..43a44535c --- /dev/null +++ b/desktop/native/folder-access.c @@ -0,0 +1,77 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static void fail(void) { + fprintf(stderr, "Folder access denied or unavailable.\n"); + exit(1); +} + +static int descend(int fd, char *path) { + char *save, *segment = strtok_r(path, "/", &save); + while (segment) { + if (!strcmp(segment, "..")) fail(); + if (strcmp(segment, ".")) { + int next = openat(fd, segment, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (next < 0) fail(); + close(fd); + fd = next; + } + segment = strtok_r(NULL, "/", &save); + } + return fd; +} + +int main(int argc, char **argv) { + if (argc != 6 || argv[1][0] != '/' || argv[4][0] == '/') fail(); + int fd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (fd < 0) fail(); + fd = descend(fd, argv[1]); + struct stat st; + if (fstat(fd, &st)) fail(); + if ((unsigned long long)st.st_dev != strtoull(argv[2], NULL, 10) || + (unsigned long long)st.st_ino != strtoull(argv[3], NULL, 10)) fail(); + if (!strcmp(argv[5], "list")) { + fd = descend(fd, argv[4]); + DIR *dir = fdopendir(fd); + if (!dir) fail(); + struct dirent *entry; + int count = 0; + while ((entry = readdir(dir)) && count < 201) { + if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) continue; + char type = entry->d_type == DT_DIR ? 'd' : entry->d_type == DT_REG ? 'f' : 'x'; + if (write(1, &type, 1) != 1) fail(); + size_t length = strlen(entry->d_name) + 1; + if (write(1, entry->d_name, length) != (ssize_t)length) fail(); + count++; + } + closedir(dir); + return 0; + } + if (strcmp(argv[5], "read")) fail(); + char *name = strrchr(argv[4], '/'); + if (name) { + *name = 0; + fd = descend(fd, argv[4]); + name++; + } else { + name = argv[4]; + } + if (!*name || !strcmp(name, ".") || !strcmp(name, "..")) fail(); + int file = openat(fd, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC); + close(fd); + if (file < 0 || fstat(file, &st) || !S_ISREG(st.st_mode) || st.st_size > 65536) fail(); + char buffer[65537]; + ssize_t total = 0, n; + while (total < 65537 && (n = read(file, buffer + total, 65537 - total)) > 0) total += n; + if (n < 0 || total > 65536) fail(); + close(file); + if (write(1, buffer, total) != total) fail(); + return 0; +} diff --git a/desktop/package.json b/desktop/package.json new file mode 100644 index 000000000..ba25fc457 --- /dev/null +++ b/desktop/package.json @@ -0,0 +1,22 @@ +{ + "name": "tanstack-desktop", + "version": "0.1.3", + "private": true, + "description": "TanStack desktop host", + "author": "TanStack", + "type": "module", + "main": "main.mjs", + "scripts": { + "dev": "install-electron && node build-device-helper.mjs && electron .", + "pack": "electron-builder --config builder.config.mjs --dir -c.forceCodeSigning=false --publish never", + "dist": "electron-builder --config builder.config.mjs --mac --publish never", + "test": "node build-device-helper.mjs && node --test *.test.mjs" + }, + "dependencies": { + "electron-updater": "^6.8.9" + }, + "devDependencies": { + "electron": "44.4.5", + "electron-builder": "^26.15.3" + } +} diff --git a/desktop/preload.cjs b/desktop/preload.cjs new file mode 100644 index 000000000..77affa2dd --- /dev/null +++ b/desktop/preload.cjs @@ -0,0 +1,94 @@ +const { contextBridge, ipcRenderer } = require('electron') + +// A small, persistent drag target also covers loading and external sign-in pages. +// It is independent of the hosted app's route and CSS. +if (process.platform === 'darwin') { + window.addEventListener( + 'DOMContentLoaded', + () => { + document.documentElement.dataset.gumDesktopMac = 'true' + const handle = document.createElement('div') + handle.setAttribute('aria-hidden', 'true') + handle.title = 'Drag window' + Object.assign(handle.style, { + position: 'fixed', + top: '4px', + left: '50%', + transform: 'translateX(-50%)', + width: '64px', + height: '20px', + zIndex: '2147483647', + display: 'var(--kody-window-grip-display, flex)', + alignItems: 'center', + justifyContent: 'center', + userSelect: 'none', + }) + handle.style.setProperty('-webkit-app-region', 'drag') + const shadow = handle.attachShadow({ mode: 'closed' }) + const grip = document.createElement('span') + Object.assign(grip.style, { + width: '32px', + height: '4px', + borderRadius: '4px', + background: 'rgba(128,128,128,0.5)', + pointerEvents: 'none', + }) + shadow.append(grip) + document.documentElement.append(handle) + }, + { once: true }, + ) +} + +if (!process.argv.includes('--kody-auth-window')) + contextBridge.exposeInMainWorld('gumDesktop', { + devices: { + status: () => ipcRenderer.invoke('gum:device:status'), + connect: () => ipcRenderer.invoke('gum:device:connect'), + shareFolder: () => ipcRenderer.invoke('gum:device:shareFolder'), + removeFolder: (id) => ipcRenderer.invoke('gum:device:removeFolder', id), + disconnect: () => ipcRenderer.invoke('gum:device:disconnect'), + }, + describe: () => ipcRenderer.invoke('gum:desktop:describe'), + updates: { + state: () => ipcRenderer.invoke('gum:desktop:updates:state'), + check: () => ipcRenderer.invoke('gum:desktop:updates:check'), + download: () => ipcRenderer.invoke('gum:desktop:updates:download'), + install: () => ipcRenderer.invoke('gum:desktop:updates:install'), + }, + browser: { + setProfile: (profileId) => + ipcRenderer.invoke('gum:browser:profile', profileId), + setBounds: (bounds) => ipcRenderer.invoke('gum:browser:bounds', bounds), + hide: () => ipcRenderer.invoke('gum:browser:hide'), + navigate: (url) => ipcRenderer.invoke('gum:browser:navigate', url), + back: () => ipcRenderer.invoke('gum:browser:back'), + forward: () => ipcRenderer.invoke('gum:browser:forward'), + reload: () => ipcRenderer.invoke('gum:browser:reload'), + find: (query, next, forward) => + ipcRenderer.invoke('gum:browser:find', query, next, forward), + print: () => ipcRenderer.invoke('gum:browser:print'), + zoom: (direction) => ipcRenderer.invoke('gum:browser:zoom', direction), + setDeviceEmulation: (preset) => + ipcRenderer.invoke('gum:browser:device', preset), + screenshot: () => ipcRenderer.invoke('gum:browser:screenshot'), + history: () => ipcRenderer.invoke('gum:browser:history'), + openDownload: (id) => ipcRenderer.invoke('gum:browser:open-download', id), + clearData: () => ipcRenderer.invoke('gum:browser:clear-data'), + addExtension: () => ipcRenderer.invoke('gum:browser:add-extension'), + removeExtension: (id) => + ipcRenderer.invoke('gum:browser:remove-extension', id), + state: () => ipcRenderer.invoke('gum:browser:state'), + onState: (listener) => { + const handler = (_event, state) => listener(state) + ipcRenderer.on('gum:browser:state-changed', handler) + return () => + ipcRenderer.removeListener('gum:browser:state-changed', handler) + }, + onShortcut: (listener) => { + const handler = (_event, shortcut) => listener(shortcut) + ipcRenderer.on('gum:browser:shortcut', handler) + return () => ipcRenderer.removeListener('gum:browser:shortcut', handler) + }, + }, + }) diff --git a/desktop/preload.test.mjs b/desktop/preload.test.mjs new file mode 100644 index 000000000..e0402f359 --- /dev/null +++ b/desktop/preload.test.mjs @@ -0,0 +1,22 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' + +const source = readFileSync(new URL('./preload.cjs', import.meta.url), 'utf8') +function exposed(argv) { + const bridges = [] + vm.runInNewContext(source, { + process: { platform: 'darwin', argv }, + window: { addEventListener() {} }, + require: () => ({ + contextBridge: { exposeInMainWorld: (name) => bridges.push(name) }, + ipcRenderer: {}, + }), + }) + return bridges +} +test('authentication windows receive no native bridge', () => { + assert.deepEqual(exposed(['electron', '--kody-auth-window']), []) + assert.deepEqual(exposed(['electron']), ['gumDesktop']) +}) diff --git a/desktop/release-version.test.mjs b/desktop/release-version.test.mjs new file mode 100644 index 000000000..13e8de9a2 --- /dev/null +++ b/desktop/release-version.test.mjs @@ -0,0 +1,38 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { assertNewerRelease } from '../scripts/chat/desktop-release-version.mjs' + +test('a fresh feed permits a valid first release', () => { + assert.doesNotThrow(() => assertNewerRelease('0.1.3', null)) + assert.throws( + () => assertNewerRelease('invalid', null), + /Invalid release version/, + ) +}) +test('existing feeds only permit increasing semantic versions', () => { + for (const [next, current] of [ + ['1.0.0', '0.99.99'], + ['1.2.0', '1.1.99'], + ['1.2.4', '1.2.3'], + ]) { + assert.doesNotThrow(() => assertNewerRelease(next, current)) + } + for (const [next, current] of [ + ['1.2.3', '1.2.3'], + ['1.2.2', '1.2.3'], + ['0.99.99', '1.0.0'], + ]) { + assert.throws( + () => assertNewerRelease(next, current), + /Release version must increase/, + ) + } +}) +test('malformed existing feeds cannot be treated as fresh', () => { + for (const current of [undefined, '', 'latest', '1.2.3-beta']) { + assert.throws( + () => assertNewerRelease('1.2.3', current), + /Invalid release version/, + ) + } +}) diff --git a/desktop/runtime-config.mjs b/desktop/runtime-config.mjs new file mode 100644 index 000000000..b0f0c3bd8 --- /dev/null +++ b/desktop/runtime-config.mjs @@ -0,0 +1,21 @@ +export const productionOrigin = 'https://tanstack.com' + +export function resolveAppUrl({ packaged, developmentUrl }) { + const url = new URL( + packaged ? productionOrigin : developmentUrl || 'http://127.0.0.1:3000/', + ) + if ( + (url.protocol !== 'https:' && + !(url.protocol === 'http:' && url.hostname === '127.0.0.1')) || + url.username || + url.password || + url.search || + url.hash || + url.pathname !== '/' + ) + throw new Error( + 'The desktop app URL must be an HTTPS origin or local 127.0.0.1 origin.', + ) + url.pathname = '/chat' + return url +} diff --git a/desktop/runtime-config.test.mjs b/desktop/runtime-config.test.mjs new file mode 100644 index 000000000..f6059f3db --- /dev/null +++ b/desktop/runtime-config.test.mjs @@ -0,0 +1,33 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { productionOrigin, resolveAppUrl } from './runtime-config.mjs' + +test('packaged app ignores development origin overrides', () => { + assert.equal( + resolveAppUrl({ packaged: true, developmentUrl: 'http://127.0.0.1:4317/' }) + .origin, + productionOrigin, + ) +}) +test('development permits the fixture origin', () => { + assert.equal( + resolveAppUrl({ packaged: false, developmentUrl: 'http://127.0.0.1:4317/' }) + .port, + '4317', + ) +}) +test('rejects credentials, insecure remote hosts, and non-origin development URLs', () => { + for (const developmentUrl of [ + 'http://example.com', + 'https://user:pass@example.com', + 'file:///tmp/app.html', + 'https://example.com/path', + 'https://example.com/?token=secret', + ]) { + assert.throws(() => resolveAppUrl({ packaged: false, developmentUrl })) + } +}) + +test('launches the consolidated chat path', () => { + assert.equal(resolveAppUrl({ packaged: true }).pathname, '/chat') +}) diff --git a/desktop/updates.mjs b/desktop/updates.mjs new file mode 100644 index 000000000..2c4075d46 --- /dev/null +++ b/desktop/updates.mjs @@ -0,0 +1,177 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' + +export async function createNativeUpdater({ + app, + dialog, + getWindow, + prepareToQuit, +}) { + let updater + if (app.isPackaged) { + const metadata = JSON.parse( + await readFile(join(app.getAppPath(), 'package.json'), 'utf8'), + ) + if (metadata.nativeUpdates) { + const { default: electronUpdater } = await import('electron-updater') + updater = electronUpdater.autoUpdater + updater.autoDownload = false + updater.autoInstallOnAppQuit = true + updater.allowDowngrade = false + } + } + const controller = createUpdateController({ + updater, + version: app.getVersion(), + dialog, + getWindow, + prepareToQuit, + }) + if (updater) { + const stop = startUpdateChecks(controller) + app.once('before-quit', stop) + } + return controller +} + +export function startUpdateChecks(controller, timers = globalThis) { + let stopped = false + const check = async () => { + if (stopped) return + await controller.check() + } + const startup = timers.setTimeout(() => void check(), 30_000) + const interval = timers.setInterval(() => void check(), 4 * 60 * 60 * 1000) + startup.unref?.() + interval.unref?.() + return () => { + stopped = true + timers.clearTimeout(startup) + timers.clearInterval(interval) + } +} + +export function createUpdateController({ + updater, + version, + dialog, + getWindow, + prepareToQuit, +}) { + let status = { status: updater ? 'idle' : 'disabled', version } + let pending + const showDialog = (options) => { + const window = getWindow() + return window + ? dialog.showMessageBox(window, options) + : dialog.showMessageBox(options) + } + if (updater) { + updater.on('checking-for-update', () => { + status = { ...status, status: 'checking', error: undefined } + }) + updater.on('update-available', (info) => { + status = { + ...status, + status: 'available', + availableVersion: info.version, + } + }) + updater.on('update-not-available', () => { + status = { status: 'current', version: version } + }) + updater.on('download-progress', (progress) => { + status = { ...status, status: 'downloading', percent: progress.percent } + }) + updater.on('update-downloaded', () => { + status = { ...status, status: 'downloaded', percent: 100 } + }) + updater.on('error', (error) => { + console.error('Desktop update failed:', error) + status = { + ...status, + status: 'error', + error: 'The desktop update could not finish. Try again later.', + } + }) + } + const run = async (action) => { + if (pending) return pending + pending = (async () => { + try { + await action() + } catch (error) { + console.error('Desktop update failed:', error) + status = { + ...status, + status: 'error', + error: 'The desktop update could not finish. Try again later.', + } + } + return { ...status } + })() + try { + return await pending + } finally { + pending = undefined + } + } + const api = { + state: () => ({ ...status }), + check: async () => { + if (!updater || status.status === 'downloaded') return api.state() + return run(() => updater.checkForUpdates()) + }, + download: async () => { + if (!updater || status.status !== 'available') return api.state() + return run(() => updater.downloadUpdate()) + }, + install: async () => { + if (!updater || status.status !== 'downloaded') return false + const answer = await showDialog({ + type: 'question', + buttons: ['Later', 'Restart and update'], + defaultId: 0, + cancelId: 0, + message: 'Restart TanStack to install the update?', + detail: + 'This closes the desktop window and its browser pages. You can also install the update the next time you quit the app.', + }) + if (answer.response !== 1) return false + // The updater closes windows before app.before-quit. Bypass hide-on-close first. + prepareToQuit() + updater.quitAndInstall() + return true + }, + checkWithDialog: async () => { + const result = await api.check() + if (result.status === 'downloaded') return api.install() + if (result.status === 'available') { + const answer = await showDialog({ + type: 'info', + buttons: ['Later', 'Download'], + defaultId: 1, + cancelId: 0, + message: `TanStack ${result.availableVersion} is available.`, + }) + if (answer.response === 1) { + const downloaded = await api.download() + if (downloaded.status === 'downloaded') await api.install() + } + return + } + await showDialog({ + type: result.status === 'error' ? 'error' : 'info', + message: + result.status === 'disabled' + ? 'Desktop updates are not configured for this build.' + : result.status === 'error' + ? result.error + : result.status === 'checking' || result.status === 'downloading' + ? 'The desktop update is still in progress.' + : 'TanStack is up to date.', + }) + }, + } + return api +} diff --git a/desktop/updates.test.mjs b/desktop/updates.test.mjs new file mode 100644 index 000000000..bffc39267 --- /dev/null +++ b/desktop/updates.test.mjs @@ -0,0 +1,103 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { createUpdateController } from './updates.mjs' + +function setup(response = 0) { + const order = [] + const updater = new EventEmitter() + updater.quitAndInstall = () => order.push('install') + const controller = createUpdateController({ + updater, + version: '0.1.0', + getWindow: () => undefined, + dialog: { showMessageBox: async () => ({ response }) }, + prepareToQuit: () => order.push('allow-close'), + }) + return { updater, controller, order } +} +test('restart requires a downloaded update and native confirmation', async () => { + const { updater, controller, order } = setup() + assert.equal(await controller.install(), false) + updater.emit('update-downloaded') + assert.equal(await controller.install(), false) + assert.deepEqual(order, []) +}) +test('explicit restart bypasses macOS hide-on-close before the updater closes windows', async () => { + const { updater, controller, order } = setup(1) + updater.emit('update-downloaded') + assert.equal(await controller.install(), true) + assert.deepEqual(order, ['allow-close', 'install']) +}) +test('simultaneous checks share one request', async () => { + const { updater, controller } = setup() + let finish + let requests = 0 + updater.checkForUpdates = () => { + requests++ + updater.emit('checking-for-update') + return new Promise((resolve) => { + finish = resolve + }) + } + const first = controller.check() + const second = controller.check() + assert.equal(requests, 1) + updater.emit('update-available', { version: '0.2.0' }) + finish() + assert.equal((await first).status, 'available') + assert.equal((await second).availableVersion, '0.2.0') +}) +test('a downloaded update survives another check without redownloading', async () => { + const { updater, controller } = setup() + updater.emit('update-downloaded') + updater.checkForUpdates = () => assert.fail('must not check again') + assert.equal((await controller.check()).status, 'downloaded') +}) + +test('background checks leave prompts to the app UI and stop on quit', async () => { + const { startUpdateChecks } = await import('./updates.mjs') + let tick + let notified = 0 + let version = '0.1.3' + let checks = 0 + let cleared = 0 + const stop = startUpdateChecks( + { + check: async () => { + checks++ + return { status: 'available', availableVersion: version } + }, + checkWithDialog: async () => { + notified++ + }, + }, + { + setTimeout: (callback) => { + tick = callback + return 1 + }, + setInterval: () => 2, + clearTimeout: () => { + cleared++ + }, + clearInterval: () => { + cleared++ + }, + }, + ) + tick() + await new Promise(setImmediate) + tick() + await new Promise(setImmediate) + assert.equal(notified, 0) + version = '0.1.4' + tick() + await new Promise(setImmediate) + assert.equal(notified, 0) + stop() + tick() + await new Promise(setImmediate) + assert.equal(checks, 3) + assert.equal(cleared, 2) +}) diff --git a/desktop/windows.test.mjs b/desktop/windows.test.mjs new file mode 100644 index 000000000..0b644a794 --- /dev/null +++ b/desktop/windows.test.mjs @@ -0,0 +1,310 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' +import { registerHooks } from 'node:module' +import { mkdtemp } from 'node:fs/promises' +import { rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +// Exercise the actual main-process wiring without launching the user's installed app. +test('desktop windows own their browser and IPC lifecycle while sharing account storage', async () => { + const root = await mkdtemp(join(tmpdir(), 'banks-windows-')) + let ready + let menu + let focused + let sequence = 0 + const windows = [] + const views = [] + const handlers = new Map() + const sessions = new Map() + const getSession = (partition = 'default') => { + if (!sessions.has(partition)) + sessions.set( + partition, + Object.assign(new EventEmitter(), { + setPermissionRequestHandler() {}, + clearData: async () => {}, + extensions: { + getAllExtensions: () => [], + loadExtension: async () => ({ + id: 'extension', + name: 'Extension', + version: '1', + }), + removeExtension() {}, + }, + }), + ) + return sessions.get(partition) + } + class Contents extends EventEmitter { + constructor(partition) { + super() + this.id = ++sequence + this.mainFrame = { url: 'https://tanstack.com/chat' } + this.url = '' + this.zoom = 1 + this.sent = [] + this.session = getSession(partition) + this.navigationHistory = { + canGoBack: () => false, + canGoForward: () => false, + } + } + setZoomMode() {} + setZoomFactor(value) { + this.zoom = value + } + getZoomFactor() { + return this.zoom + } + setWindowOpenHandler(handler) { + this.openHandler = handler + } + async loadURL(url) { + this.url = url + this.mainFrame.url = url + this.emit('did-navigate', {}, url) + this.emit('did-finish-load') + } + getURL() { + return this.url + } + getTitle() { + return this.url + } + isLoading() { + return false + } + isDestroyed() { + return !!this.destroyed + } + send(channel, value) { + this.sent.push({ channel, value }) + } + close() { + this.destroyed = true + this.emit('destroyed') + } + enableDeviceEmulation(value) { + this.device = value + } + disableDeviceEmulation() { + this.device = undefined + } + findInPage() {} + stopFindInPage() {} + } + class Window extends EventEmitter { + static getFocusedWindow() { + return focused + } + constructor(options) { + super() + this.options = options + this.webContents = new Contents() + this.children = new Set() + this.contentView = { + addChildView: (view) => this.children.add(view), + removeChildView: (view) => this.children.delete(view), + getBounds: () => ({ x: 0, y: 0, width: 1320, height: 900 }), + } + windows.push(this) + focused = this + } + loadURL(url) { + return this.webContents.loadURL(url) + } + isDestroyed() { + return !!this.destroyed + } + close() { + this.destroyed = true + this.webContents.close() + this.emit('closed') + } + show() {} + focus() { + focused = this + } + } + class View { + constructor(options) { + this.webContents = new Contents(options.webPreferences.partition) + views.push(this) + } + setBounds(value) { + this.bounds = value + } + } + const app = Object.assign(new EventEmitter(), { + isPackaged: true, + setName() {}, + setPath() {}, + getPath: () => root, + getVersion: () => 'test', + whenReady: () => ({ + then: (callback) => { + ready = callback + }, + }), + quit() {}, + }) + globalThis.__banksWindowTest = { + app, + BrowserWindow: Window, + WebContentsView: View, + Menu: { + buildFromTemplate: (value) => value, + setApplicationMenu: (value) => { + menu = value + }, + }, + ipcMain: { + handle: (name, handler) => { + assert.ok(!handlers.has(name), `duplicate IPC handler ${name}`) + handlers.set(name, handler) + }, + }, + clipboard: {}, + dialog: {}, + shell: { openExternal: async () => {} }, + safeStorage: {}, + session: { defaultSession: getSession() }, + } + const hooks = registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'electron') + return { url: 'test:electron', shortCircuit: true } + if ( + context.parentURL?.includes('/desktop/main.mjs') && + specifier === './connected-device.mjs' + ) + return { url: 'test:device', shortCircuit: true } + if ( + context.parentURL?.includes('/desktop/main.mjs') && + specifier === './updates.mjs' + ) + return { url: 'test:updates', shortCircuit: true } + return next(specifier, context) + }, + load(url, context, next) { + if (url === 'test:electron') + return { + format: 'module', + shortCircuit: true, + source: + 'export const { app, BrowserWindow, Menu, WebContentsView, clipboard, dialog, ipcMain, shell, safeStorage, session } = globalThis.__banksWindowTest', + } + if (url === 'test:device') + return { + format: 'module', + shortCircuit: true, + source: + 'export async function createConnectedDevice(){return {stop(){},status(){return {connected:false}}}}', + } + if (url === 'test:updates') + return { + format: 'module', + shortCircuit: true, + source: + 'export async function createNativeUpdater(){return {state(){return {}},checkWithDialog(){}}}', + } + return next(url, context) + }, + }) + try { + await import('./main.mjs') + await ready() + const first = windows[0] + const file = menu.find((entry) => entry.label === 'File').submenu + file.find((entry) => entry.label === 'New Window').click() + const second = windows[1] + const invoke = (window, method, ...args) => + handlers.get(`gum:browser:${method}`)( + { + sender: window.webContents, + senderFrame: window.webContents.mainFrame, + }, + ...args, + ) + await Promise.all([ + invoke(first, 'profile', 'same-account'), + invoke(second, 'profile', 'same-account'), + ]) + assert.equal(views.length, 2) + assert.equal(views[0].webContents.session, views[1].webContents.session) + await invoke(first, 'navigate', 'https://one.example') + await invoke(second, 'navigate', 'https://two.example') + assert.equal(invoke(first, 'state').url, 'https://one.example/') + assert.equal(invoke(second, 'state').url, 'https://two.example/') + assert.deepEqual( + invoke(first, 'history').map((entry) => entry.url), + ['https://two.example/', 'https://one.example/'], + ) + invoke(first, 'device', 'phone') + assert.equal(invoke(second, 'state').deviceEmulation, 'off') + invoke(first, 'bounds', { x: 0, y: 0, width: 500, height: 500 }) + invoke(second, 'bounds', { x: 0, y: 0, width: 600, height: 500 }) + invoke(first, 'hide') + assert.equal(first.children.size, 0) + assert.equal(second.children.size, 1) + assert.throws( + () => + handlers.get('gum:browser:state')({ + sender: views[0].webContents, + senderFrame: views[0].webContents.mainFrame, + }), + /app frame/, + ) + assert.throws( + () => + handlers.get('gum:browser:state')({ + sender: first.webContents, + senderFrame: { url: first.webContents.getURL() }, + }), + /app frame/, + ) + await second.loadURL('https://tanstack.com/chat/w/personal/b/conversation') + file + .find((entry) => entry.label === 'Open Conversation in New Window') + .click() + assert.equal(windows[2].webContents.getURL(), second.webContents.getURL()) + first.close() + assert.equal(views[0].webContents.isDestroyed(), true) + assert.equal(views[1].webContents.isDestroyed(), false) + assert.equal(invoke(second, 'state').url, 'https://two.example/') + assert.throws(() => invoke(first, 'state'), /app frame/) + second.webContents.openHandler({ + url: 'https://tanstack.com/chat/w/personal/b/other', + frameName: '_blank', + }) + assert.equal( + windows[3].webContents.getURL(), + 'https://tanstack.com/chat/w/personal/b/other', + ) + await invoke(second, 'profile', 'different-account') + assert.deepEqual(invoke(second, 'history'), []) + assert.equal(invoke(second, 'state').url, '') + assert.notEqual( + views.at(-1).webContents.session, + views[1].webContents.session, + ) + const viewCount = views.length + const pending = invoke(windows[3], 'profile', 'closing-account') + windows[3].close() + await pending + assert.equal( + views.length, + viewCount, + 'closing during setup must not create an orphan browser', + ) + for (const window of windows) if (!window.isDestroyed()) window.close() + } finally { + hooks.deregister() + delete globalThis.__banksWindowTest + // Writes finish asynchronously, keep the temporary directory for the process lifetime. + process.once('exit', () => rmSync(root, { recursive: true, force: true })) + } +}) diff --git a/docs/builder-retirement.md b/docs/builder-retirement.md new file mode 100644 index 000000000..9cad882dd --- /dev/null +++ b/docs/builder-retirement.md @@ -0,0 +1,11 @@ +# Builder retirement + +TanChat replaces the standalone Builder product. `/builder` redirects to `/chat`, the Builder menu item is removed, and the esbuild experiment is retired. Existing project-specific links redirect to their TanChat equivalents. + +Project APIs now live under `/api/chat`. Their callers and tests use those routes. The project interface lives under `src/chat/components/projects`. + +The existing project tables remain because TanChat's saved-project tools still use them, including revisions, events, snapshots, and usage accounting. Dropping those tables would remove a working TanChat capability. No database deletion is included in this change. + +Unpublished browser-sandbox integration remains disabled as documented in `deferred-browser-sandbox/README.md`. + +Verification: the latest harness run passed all 226 files and 2,221 tests. The production build completed successfully. Full pre-commit verification is still being resolved before this change can be published. diff --git a/docs/deferred-browser-sandbox/README.md b/docs/deferred-browser-sandbox/README.md new file mode 100644 index 000000000..c88b7944c --- /dev/null +++ b/docs/deferred-browser-sandbox/README.md @@ -0,0 +1,5 @@ +# Browser sandbox integration + +The unpublished SDK integration is preserved here as `.disabled` source files. It is excluded from compilation and package installation. Re-enable these files only after the SDK and runtime have a reproducible published release or source dependency, then restore the Vite setup and runtime tests. + +Existing TanStack.com example previews retain their current implementation. TanChat workspace preview controls are disabled for this alpha. diff --git a/docs/deferred-browser-sandbox/scripts/local-browser-sandbox-preview.mjs.disabled b/docs/deferred-browser-sandbox/scripts/local-browser-sandbox-preview.mjs.disabled new file mode 100644 index 000000000..d0c84006a --- /dev/null +++ b/docs/deferred-browser-sandbox/scripts/local-browser-sandbox-preview.mjs.disabled @@ -0,0 +1,20 @@ +import {createServer} from 'node:http' +import {readFileSync} from 'node:fs' +import {dirname,resolve} from 'node:path' +import {fileURLToPath} from 'node:url' +import {createHash} from 'node:crypto' + +const root=dirname(fileURLToPath(import.meta.resolve('@tanstack/browser-sandbox-experimental'))) +const expectedSdkManifestSHA256=process.env.SANDBOX_SDK_MANIFEST_SHA256 +if(!expectedSdkManifestSHA256?.match(/^[a-f0-9]{64}$/))throw Error('Set SANDBOX_SDK_MANIFEST_SHA256 to the exact tested SDK manifest hash') +if(createHash('sha256').update(readFileSync(resolve(root,'manifest.json'))).digest('hex')!==expectedSdkManifestSHA256)throw Error('Expected the exact tested local SDK package') +const hosting=JSON.parse(readFileSync(resolve(root,'preview-host/hosting.json'),'utf8')) +const server=createServer((request,response)=>{ + const path=new URL(request.url,'http://localhost').pathname + const route=hosting.routes.find(route=>route.path===path&&route.method===request.method) + if(!route){response.writeHead(hosting.fallbackStatus,hosting.fallbackHeaders);response.end('No workspace attached');return} + response.writeHead(200,route.headers) + response.end(readFileSync(resolve(root,'preview-host',route.file))) +}) +server.listen(4199,'127.0.0.1',()=>console.log('Local SDK preview host: http://127.0.0.1:4199')) +for(const signal of ['SIGINT','SIGTERM'])process.once(signal,()=>server.close(error=>{if(error){console.error(error);process.exitCode=1}})) diff --git a/docs/deferred-browser-sandbox/scripts/local-browser-sandbox.ts.disabled b/docs/deferred-browser-sandbox/scripts/local-browser-sandbox.ts.disabled new file mode 100644 index 000000000..ac47d27d5 --- /dev/null +++ b/docs/deferred-browser-sandbox/scripts/local-browser-sandbox.ts.disabled @@ -0,0 +1,148 @@ +import type { Plugin } from 'vite' +import { readFileSync, readdirSync, realpathSync } from 'node:fs' +import { dirname, resolve, relative, sep, extname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' +import { resolveSDKRuntimeProfile } from '@tanstack/browser-sandbox-experimental' + +export const localBrowserSandboxExcludedPatterns = [ + 'node_modules', + '.git', + 'dist', + '.output', + '.netlify', + '.vercel', + '.DS_Store', + '.nitro', + 'test-results', + 'playwright-report', + '.tanstack-start/build', +] as const + +export function shouldExcludeLocalBrowserSandboxPath(path: string) { + const normalized = path.split(sep).join('/') + return localBrowserSandboxExcludedPatterns.some( + (pattern) => normalized === pattern || normalized.startsWith(`${pattern}/`), + ) +} + +export function classifyLocalBrowserSandboxFile(bytes: Buffer) { + try { + const text = new TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true, + }).decode(bytes) + const encoded = new TextEncoder().encode(text) + if ( + !text.includes('\0') && + encoded.byteLength === bytes.byteLength && + encoded.every((byte, index) => byte === bytes[index]) + ) { + return { type: 'text' as const, value: text } + } + } catch { + // Files that are not lossless UTF-8 must remain byte-for-byte intact. + } + + return { type: 'binary' as const, value: bytes.toString('base64') } +} + +// Local consumer experiment only. No production hosting or dependency fallback. +export function localBrowserSandbox(): Plugin | false { + if (process.env.VITE_LOCAL_BROWSER_SANDBOX !== '1') return false + return { + name: 'local-browser-sandbox', + apply: 'serve', + async configureServer(server) { + const root = dirname(fileURLToPath(import.meta.resolve('@tanstack/browser-sandbox-experimental'))) + const manifest = JSON.parse(readFileSync(resolve(root, 'manifest.json'), 'utf8')) + const sha = (bytes: string | Buffer) => createHash('sha256').update(bytes).digest('hex') + const expectedSdkManifestSHA256 = process.env.SANDBOX_SDK_MANIFEST_SHA256 + if (!expectedSdkManifestSHA256?.match(/^[a-f0-9]{64}$/)) throw Error('Set SANDBOX_SDK_MANIFEST_SHA256 to the exact tested SDK manifest hash') + if (sha(readFileSync(resolve(root, 'manifest.json'))) !== expectedSdkManifestSHA256) throw Error('Local integration requires the exact tested SDK package') + const runtimeProfile = resolveSDKRuntimeProfile(manifest, 'tanstack-start') + if (!process.env.SANDBOX_LOCAL_SOURCE || !process.env.SANDBOX_LOCAL_FIXTURE) throw Error('Set SANDBOX_LOCAL_SOURCE and SANDBOX_LOCAL_FIXTURE') + const source = realpathSync(process.env.SANDBOX_LOCAL_SOURCE) + const fixture = realpathSync(process.env.SANDBOX_LOCAL_FIXTURE) + const provenance = JSON.parse(readFileSync(resolve(fixture, 'provenance.json'), 'utf8')) + const files: Record = {} + const binaryFiles: Record = {} + const sourceHashes: Record = {} + function collect(directory: string) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name) + const name = relative(source, path) + if (shouldExcludeLocalBrowserSandboxPath(name)) continue + if (entry.isDirectory()) { collect(path); continue } + if (!entry.isFile()) throw Error('Unexpected project entry: ' + path) + const bytes = readFileSync(path) + const classified = classifyLocalBrowserSandboxFile(bytes) + const normalizedName = name.split(sep).join('/') + const projectPath = '/project/' + normalizedName + if (classified.type === 'text') files[projectPath] = classified.value + else binaryFiles[projectPath] = classified.value + sourceHashes[normalizedName] = sha(bytes) + } + } + collect(source) + if (sourceHashes['package.json'] !== provenance.sourceSHA256) throw Error('Start source changed; review the portable dependency graph') + const original = JSON.parse(files['/project/package.json']) + const portable = readFileSync(resolve(fixture, 'package.json'), 'utf8') + const expected = { + ...original, + dependencies: { ...original.dependencies, ...provenance.graphChanges.dependencies }, + devDependencies: { ...original.devDependencies, ...provenance.graphChanges.devDependencies }, + overrides: provenance.graphChanges.overrides, + } + if (JSON.stringify(JSON.parse(portable)) !== JSON.stringify(expected)) throw Error('Undeclared dependency changes') + files['/project/package.json'] = portable + const lockfile = readFileSync(resolve(fixture, 'package-lock.json')) + files['/project/package-lock.json'] = lockfile.toString('utf8') + files['/project/SDK-SOURCE-LICENSE.txt'] = readFileSync(resolve(source, '../../../LICENSE'), 'utf8') + const projectHash = createHash('sha256') + for (const path of [...Object.keys(files), ...Object.keys(binaryFiles)].sort()) { + const pathBytes = Buffer.from(path) + const fileBytes = Object.hasOwn(files, path) + ? Buffer.from(files[path]) + : Buffer.from(binaryFiles[path], 'base64') + const lengths = Buffer.allocUnsafe(8) + lengths.writeUInt32BE(pathBytes.byteLength, 0) + lengths.writeUInt32BE(fileBytes.byteLength, 4) + projectHash.update(lengths).update(pathBytes).update(fileBytes) + } + const identity = { + version: 1, + sdkManifestSHA256: expectedSdkManifestSHA256, + projectSHA256: projectHash.digest('hex'), + configSHA256: sha(JSON.stringify({appPort: 3000, env: provenance.env})), + } + server.middlewares.use((request, response, next) => { + const path = new URL(request.url!, 'http://localhost').pathname + if (!path.startsWith('/__sandbox-local/')) return next() + if (request.method !== 'GET') { response.statusCode = 405; response.end(); return } + response.setHeader('Cache-Control', 'no-store') + for (const [name, value] of Object.entries(runtimeProfile.ownerHeaders)) response.setHeader(name, value) + response.setHeader('Cross-Origin-Resource-Policy', 'same-origin') + try { + if (path === '/__sandbox-local/project.json' || path === '/__sandbox-local/config.json') { + response.setHeader('Content-Type', 'application/json') + response.end(JSON.stringify(path.endsWith('/project.json') ? { + files, + binaryFiles, + sourceHashes, + provenance, + identity, + } : {previewOrigin: 'http://127.0.0.1:4199', appPort: 3000, env: provenance.env, runtimeProfile})) + return + } + if (!path.startsWith('/__sandbox-local/runtime/')) { response.statusCode = 404; response.end(); return } + const runtime = realpathSync(resolve(root, 'runtime')) + const file = realpathSync(resolve(runtime, decodeURIComponent(path.slice('/__sandbox-local/runtime/'.length)))) + if (!file.startsWith(runtime + sep)) throw Error('Outside runtime assets') + response.setHeader('Content-Type', ({'.js':'text/javascript','.mjs':'text/javascript','.wasm':'application/wasm','.json':'application/json'} as Record)[extname(file)] ?? 'application/octet-stream') + response.end(readFileSync(file)) + } catch { response.statusCode = 404; response.end() } + }) + }, + } +} diff --git a/docs/deferred-browser-sandbox/src/components/examples/ExampleNativeWorkbench.client.tsx.disabled b/docs/deferred-browser-sandbox/src/components/examples/ExampleNativeWorkbench.client.tsx.disabled new file mode 100644 index 000000000..2fdaf186a --- /dev/null +++ b/docs/deferred-browser-sandbox/src/components/examples/ExampleNativeWorkbench.client.tsx.disabled @@ -0,0 +1,511 @@ +import * as React from 'react' +import { BrowserIcon, FolderOpenIcon, PlayIcon, TerminalWindowIcon } from '@phosphor-icons/react' +import { NativeOwnerClient, URLPreview } from '@tanstack/browser-sandbox-experimental' +import { ButtonGroup } from '~/components/ButtonGroup' +import { FileExplorer, type FileExplorerNode } from '~/components/FileExplorer' +import { useTheme } from '~/components/ThemeProvider' +import { Button } from '~/components/ds/ui' +import { Tooltip } from '~/ui' +import type { ExampleDefinition } from '~/utils/example-workspace' +import { CodeMirrorEditor } from './CodeMirrorEditor.client' +import { SandboxBrowser } from './SandboxBrowser.client' +import { WebContainerProcessTerminalPanel } from './WebContainerTerminal.client' +import { NativeTerminal } from './NativeTerminal.client' + +type Preview = Awaited> +const ownerOrigin = import.meta.env.VITE_NATIVE_OWNER_ORIGIN || (import.meta.env.DEV ? 'http://127.0.0.1:4197' : '') +const previewOriginBase = import.meta.env.VITE_NATIVE_PREVIEW_ORIGIN || (import.meta.env.DEV ? 'http://127.0.0.1:4199' : '') +const previewHostSuffix = import.meta.env.VITE_NATIVE_PREVIEW_HOST_SUFFIX || '' +const previewOrigin = (() => { + if (!previewHostSuffix) return previewOriginBase + if (!/^\.[a-z0-9]+(?:[.-][a-z0-9]+)*$/i.test(previewHostSuffix)) throw Error('Invalid preview host suffix') + const url = new URL(previewOriginBase) + url.hostname = crypto.randomUUID().replaceAll('-', '') + previewHostSuffix + return url.origin +})() + +function decode(definition: ExampleDefinition) { + const files: Record = {} + for (const [path, source] of Object.entries(definition.workspace.files)) { + files['/project' + path] = source + } + for (const [path, value] of Object.entries(definition.workspace.binaryFiles ?? {})) { + files['/project' + path] = Uint8Array.from(atob(value), character => character.charCodeAt(0)) + } + return files +} + +function createFileTree(paths: string[]) { + const root: FileExplorerNode[] = [] + for (const path of paths) { + const parts = path.split('/').filter(Boolean) + let children = root + for (const [index, name] of parts.entries()) { + const nodePath = '/' + parts.slice(0, index + 1).join('/') + const last = index === parts.length - 1 + let node = children.find(candidate => candidate.path === nodePath) + if (!node) { + node = { children: last ? undefined : [], depth: index, name, path: nodePath, type: last ? 'file' : 'dir' } + children.push(node) + } + if (node.children) children = node.children + } + } + return root +} + +function formatProgress(phase: string) { + const installed = /^dependency-installed:(\d+)\/(\d+)$/.exec(phase) + if (installed) { + const completed = Number(installed[1]) + const total = Number(installed[2]) + return `Installing dependencies ${completed}/${total}` + } + if (phase === 'mounting-workspace' || phase === 'workspace-mounted') return 'Preparing project' + if (phase.startsWith('dependency-') || phase.startsWith('dependencies-')) return 'Installing dependencies' + if (phase === 'vite-listening' || phase === 'entry-ready') return 'Opening preview' + if (phase.startsWith('entry-') || phase.startsWith('async-transform-')) return 'Loading app' + if (phase === 'native-bindings-ready' || phase.startsWith('config-') || phase.startsWith('vite-')) + return 'Starting dev server' + return null +} + +export function ExampleNativeWorkbench({ definition, exampleId }: { + definition: ExampleDefinition + exampleId: string +}) { + const { resolvedTheme } = useTheme() + const initialPath = '/' + (definition.initialFile ?? definition.workspace.entry).replace(/^\//, '') + const [files, setFiles] = React.useState | null>(null) + const [loadedFor, setLoadedFor] = React.useState('') + const [activePath, setActivePath] = React.useState(initialPath) + const [showFiles, setShowFiles] = React.useState(true) + const [status, setStatus] = React.useState('Loading example') + const [installProgress, setInstallProgress] = React.useState(null) + const [output, setOutput] = React.useState('') + const [outputGeneration, setOutputGeneration] = React.useState(0) + const [showOutput, setShowOutput] = React.useState(false) + const [consoleTab, setConsoleTab] = React.useState<'terminal' | 'output'>('terminal') + const [terminalFocusKey, setTerminalFocusKey] = React.useState(0) + const [showPreview, setShowPreview] = React.useState(true) + const [mobileView, setMobileView] = React.useState<'preview' | 'code' | 'output'>('preview') + const [codePanelPercent, setCodePanelPercent] = React.useState(50) + const [terminalPanelPercent, setTerminalPanelPercent] = React.useState(42) + const [previewUrl, setPreviewUrl] = React.useState(previewOrigin + '/') + const [running, setRunning] = React.useState(false) + const [busy, setBusy] = React.useState(false) + const [error, setError] = React.useState('') + const ownerRef = React.useRef(null) + const previewRef = React.useRef(null) + const clientRef = React.useRef(null) + const terminalSessionRef = React.useRef | null>(null) + const previewSessionRef = React.useRef(null) + const pendingWritesRef = React.useRef(new Map()) + const writeTimerRef = React.useRef | undefined>(undefined) + const writingRef = React.useRef>(Promise.resolve()) + const autoStartedRef = React.useRef('') + const splitRef = React.useRef(null) + const tabStripRef = React.useRef(null) + const compactWorkbenchRef = React.useRef(null) + const previewPanelRef = React.useRef(null) + const resizeRef = React.useRef<{ pointerId: number; previousCursor: string; previousUserSelect: string } | null>(null) + const terminalResizeRef = React.useRef<{ pointerId: number; previousCursor: string; previousUserSelect: string } | null>(null) + const relativePaths = React.useMemo(() => files + ? Object.keys(files).filter(path => path.startsWith('/project/')).map(path => path.slice('/project'.length)).sort() + : [], [files]) + const tree = React.useMemo(() => createFileTree(relativePaths), [relativePaths]) + const activeSource = files?.['/project' + activePath] + + React.useEffect(() => { + const split = splitRef.current + if (!split) return + const observer = new ResizeObserver(entries => { + const compact = entries[0].contentRect.width < 760 + if (compact && compactWorkbenchRef.current !== true) setShowFiles(false) + compactWorkbenchRef.current = compact + }) + observer.observe(split) + return () => observer.disconnect() + }, []) + + React.useEffect(() => { + if (showFiles) return + const strip = tabStripRef.current + const active = strip?.querySelector('[aria-current="page"]') + if (!strip || !active) return + strip.scrollLeft += active.getBoundingClientRect().left - strip.getBoundingClientRect().left - 8 + }, [activePath, showFiles]) + + React.useEffect(() => { + setFiles(null) + setLoadedFor('') + setActivePath(initialPath) + setStatus('Loading example') + setError('') + try { + const decoded = decode(definition) + if (typeof decoded['/project' + initialPath] !== 'string') throw Error('Example entry file is missing') + setFiles(decoded) + setLoadedFor(exampleId) + setStatus('Ready') + } catch (cause) { setError(String(cause)); setStatus('Unavailable') } + return () => { + if (writeTimerRef.current) clearTimeout(writeTimerRef.current) + previewSessionRef.current?.close() + previewSessionRef.current = null + const client = clientRef.current + clientRef.current = null + const session = terminalSessionRef.current + terminalSessionRef.current = null + if (client) void (async () => { + try { await (await session)?.dispose() } catch { /* The owner may already be closing. */ } + await client.dispose().catch(() => undefined) + client.close() + })() + } + }, [definition, exampleId, initialPath]) + + React.useEffect(() => { + if (!running) return + const timer = window.setInterval(() => { + const preview = previewSessionRef.current + if (!preview) return + void preview.inspect().then(state => setPreviewUrl(state.url)).catch(() => undefined) + }, 1000) + return () => window.clearInterval(timer) + }, [running]) + + function flushWrites() { + if (writeTimerRef.current) clearTimeout(writeTimerRef.current) + writeTimerRef.current = undefined + const client = clientRef.current + if (!client) return writingRef.current + writingRef.current = writingRef.current.then(async () => { + for (const [path, value] of pendingWritesRef.current) { + await client.writeFile(path, value) + if (pendingWritesRef.current.get(path) === value) pendingWritesRef.current.delete(path) + } + }) + return writingRef.current + } + + function updateSource(value: string) { + const path = '/project' + activePath + setFiles(current => current ? { ...current, [path]: value } : current) + if (!clientRef.current || !running) return + pendingWritesRef.current.set(path, value) + if (writeTimerRef.current) clearTimeout(writeTimerRef.current) + writeTimerRef.current = setTimeout(() => { void flushWrites().catch(cause => setError(String(cause))) }, 150) + } + + async function selectFile(path: string) { + if (!files) return + try { + await flushWrites() + const client = clientRef.current + if (client && running && typeof files['/project' + path] === 'string') { + const value = new TextDecoder('utf-8', { fatal: true }).decode(await client.readFile('/project' + path)) + setFiles(current => current ? { ...current, ['/project' + path]: value } : current) + } + setActivePath(path) + } catch (cause) { setError(String(cause)) } + } + + async function runTerminalCommand(line: string, cwd: string, onOutput: (text: string) => void, signal?: AbortSignal, + onControl?: (control: Pick>['runCommand']>, 'writeInput' | 'endInput' | 'interrupt' | 'resize'>) => void, + size?: { columns: number; rows: number }) { + const client = clientRef.current + if (!client || !running) throw Error('The example is still starting') + await flushWrites() + if (!terminalSessionRef.current) { + const opening = client.openTerminalSession(cwd) + terminalSessionRef.current = opening + void opening.catch(() => { if (terminalSessionRef.current === opening) terminalSessionRef.current = null }) + } + const terminalSession = await terminalSessionRef.current + if (clientRef.current !== client) throw Error('The example was restarted') + const command = terminalSession.runCommand(line, onOutput, size) + const abort = () => command.interrupt() + signal?.addEventListener('abort', abort, { once: true }) + if (signal?.aborted) abort() + onControl?.(command) + let result: Awaited> + try { result = await command.result } + finally { signal?.removeEventListener('abort', abort) } + for (const path of result.changedPaths) { + if (!path.startsWith('/project/')) continue + try { + const value = new TextDecoder('utf-8', { fatal: true }).decode(await client.readFile(path)) + setFiles(current => current ? { ...current, [path]: value } : current) + } catch { + setFiles(current => { + if (!current || !Object.hasOwn(current, path)) return current + const next = { ...current } + delete next[path] + return next + }) + } + } + if (result.changedPaths.includes('/project/node_modules') || result.changedPaths.some(path => + path.startsWith('/project/') && !path.startsWith('/project/node_modules/') && + path !== '/project/package.json' && path !== '/project/package-lock.json')) navigatePreview(previewUrl) + return result + } + + async function closeRuntime() { + previewSessionRef.current?.close() + previewSessionRef.current = null + const client = clientRef.current + clientRef.current = null + const session = terminalSessionRef.current + terminalSessionRef.current = null + if (!client) return + try { await (await session)?.dispose() } + finally { await client.dispose().finally(() => client.close()) } + } + + async function loadOwner() { + const frame = ownerRef.current + if (!frame) throw Error('Native owner frame is missing') + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { window.removeEventListener('message', listener); reject(Error('Native owner frame did not load')) }, 10000) + const listener = (event: MessageEvent) => { + if (event.source !== frame.contentWindow || event.origin !== ownerOrigin || event.data !== 'native-owner-ready') return + clearTimeout(timeout) + window.removeEventListener('message', listener) + resolve() + } + window.addEventListener('message', listener) + frame.src = ownerOrigin + '/owner.html' + }) + if (!frame.contentWindow) throw Error('Native owner frame is unavailable') + return NativeOwnerClient.connect(frame.contentWindow, ownerOrigin, previewOrigin) + } + + async function run() { + if (!files || busy || !previewRef.current) return + setBusy(true) + setError('') + setOutput('') + setInstallProgress(null) + setOutputGeneration(value => value + 1) + try { + if (clientRef.current) { + setStatus('Restarting example') + await flushWrites() + await closeRuntime() + setRunning(false) + } + setStatus('Connecting native browser runtime') + const client = await loadOwner() + clientRef.current = client + client.subscribeEvents(event => { + if (event.type === 'progress') { + const progress = formatProgress(event.phase) + const installed = /^dependency-installed:(\d+)\/(\d+)$/.exec(event.phase) + setInstallProgress(installed && Number(installed[2]) > 0 + ? Math.min(1, Number(installed[1]) / Number(installed[2])) : null) + if (progress) setStatus(progress) + } + if (event.type === 'output') setOutput(previous => (previous + event.text).slice(-64000)) + if (event.type === 'diagnostic') setError(event.error) + }) + const options = { + workspaceRoot: '/project', installCommand: 'pnpm install', startCommand: 'pnpm run dev', + ...(exampleId === 'basic-ssr-file-based' ? { previewPort: 3000 } : {}), + } + const port = await client.start(files, options) + setStatus('App listening on virtual port ' + port) + previewSessionRef.current = await URLPreview.mount(previewRef.current, { + origin: previewOrigin, + server: { fetch: request => client.fetch(request), revision: () => client.workspaceRevision() }, + connectWebSocket: (url, protocols) => client.connectWebSocket(previewOrigin, url, protocols), + ...(exampleId === 'basic-ssr-file-based' ? { + scriptOrigins: ['https://unpkg.com'], connectOrigins: ['https://jsonplaceholder.typicode.com'], + } : {}), + }) + setRunning(true) + setPreviewUrl(previewOrigin + '/') + setStatus('Preview ready') + } catch (cause) { + setError(String(cause)) + setStatus('Failed') + await closeRuntime().catch(cleanup => setError(previous => previous + '\nCleanup failed: ' + String(cleanup))) + } finally { setBusy(false) } + } + + React.useEffect(() => { + if (!files || loadedFor !== exampleId || autoStartedRef.current === exampleId) return + autoStartedRef.current = exampleId + void run() + }, [exampleId, files, loadedFor]) + + function resize(event: React.PointerEvent) { + if (event.button !== 0 || !splitRef.current) return + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + resizeRef.current = { + pointerId: event.pointerId, + previousCursor: document.body.style.cursor, + previousUserSelect: document.body.style.userSelect, + } + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + } + + function moveResize(event: React.PointerEvent) { + if (resizeRef.current?.pointerId !== event.pointerId || !splitRef.current) return + const rect = splitRef.current.getBoundingClientRect() + setCodePanelPercent(Math.max(25, Math.min(75, ((event.clientX - rect.left) / rect.width) * 100))) + } + + function finishResize(event: React.PointerEvent) { + const resize = resizeRef.current + if (!resize || resize.pointerId !== event.pointerId) return + document.body.style.cursor = resize.previousCursor + document.body.style.userSelect = resize.previousUserSelect + resizeRef.current = null + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) + } + + function startTerminalResize(event: React.PointerEvent) { + if (event.button !== 0 || !previewPanelRef.current) return + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + terminalResizeRef.current = { + pointerId: event.pointerId, + previousCursor: document.body.style.cursor, + previousUserSelect: document.body.style.userSelect, + } + document.body.style.cursor = 'row-resize' + document.body.style.userSelect = 'none' + } + + function moveTerminalResize(event: React.PointerEvent) { + if (terminalResizeRef.current?.pointerId !== event.pointerId || !previewPanelRef.current) return + const rect = previewPanelRef.current.getBoundingClientRect() + setTerminalPanelPercent(Math.max(18, Math.min(70, ((rect.bottom - event.clientY) / rect.height) * 100))) + } + + function finishTerminalResize(event: React.PointerEvent) { + const resize = terminalResizeRef.current + if (!resize || resize.pointerId !== event.pointerId) return + document.body.style.cursor = resize.previousCursor + document.body.style.userSelect = resize.previousUserSelect + terminalResizeRef.current = null + if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId) + } + + function navigatePreview(value: string) { + const preview = previewSessionRef.current + if (!preview) return + try { + const target = new URL(value, previewUrl) + if (target.origin !== previewOrigin) throw Error('Preview navigation must stay in this workspace') + preview.navigate(target.pathname + target.search + target.hash) + setPreviewUrl(target.href) + } catch (cause) { setError(String(cause)) } + } + + const bootOverlayVisible = !running || busy + const rightPaneVisible = showPreview || showOutput + + return
+
+
+ + + + {activePath} +
+
+ + + + + + + +
+
+
+ + {(['preview', 'code', 'output'] as const).map(view => )} + +
+
+
+
+ setShowFiles(false)} prefetchFileContent={() => {}} setCurrentPath={path => void selectFile(path)} /> +
+
+ {relativePaths.map(path => )} +
+
+ {typeof activeSource === 'string' + ? void run()} /> + :

This file is binary.

} +
+
+
+
+
setCodePanelPercent(50)} onKeyDown={event => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); setCodePanelPercent(value => Math.max(25, Math.min(75, value + (event.key === 'ArrowRight' ? 2 : -2)))) } }} className={`${rightPaneVisible ? 'lg:flex' : 'lg:hidden'} group relative hidden w-2 shrink-0 touch-none cursor-col-resize items-center justify-center bg-background-default hover:bg-blue-500/15 focus-visible:outline-2 focus-visible:outline-blue-500`}> +
+
+
+
+
+ {}} onForward={() => {}} onNavigate={navigatePreview} onReload={() => navigatePreview(previewUrl)}> +
+ +
+ {bootOverlayVisible &&
+
+ + {error ? 'Could not start example' : installProgress === null ? status : `Installing dependencies... ${Math.round(installProgress * 100)}%`} +
+
+ +
+ {error &&

{error}

} +
} +
+ {showOutput && showPreview &&
setTerminalPanelPercent(42)} onKeyDown={event => { if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); setTerminalPanelPercent(value => Math.max(18, Math.min(70, value + (event.key === 'ArrowUp' ? 2 : -2)))) } }} className="hidden h-1.5 shrink-0 cursor-row-resize touch-none border-y border-border-default bg-background-subtle hover:bg-background-elevated focus-visible:outline-2 focus-visible:outline-blue-500 lg:block" />} +
+
+ + +
+
+ {running && { + const client = clientRef.current + if (!client) throw Error('The example is not running') + return client.listTerminalCommands() + }} onListDirectory={async path => { + const client = clientRef.current + if (!client) throw Error('The example is not running') + return client.listDirectory(path) + }} theme={resolvedTheme} />} +
+
+
+
+
+