From 48e52540d1a8ac720611ec04fd566bb03e515e09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:51:39 -0700 Subject: [PATCH 01/17] fix(macos): verify Full Disk Access and re-arm notices (#11716) * fix(macos): verify full disk access status * perf(macos): keep full disk probe off main thread --- src/main/ipc/developer-permissions.test.ts | 23 +++++++ src/main/ipc/developer-permissions.ts | 21 +------ .../macos-full-disk-access-status.test.ts | 63 +++++++++++++++++++ src/main/macos-full-disk-access-status.ts | 44 +++++++++++++ src/main/macos-tcc-prompt-notice.test.ts | 38 +++++++++-- src/main/macos-tcc-prompt-notice.ts | 26 ++++++-- 6 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 src/main/macos-full-disk-access-status.test.ts create mode 100644 src/main/macos-full-disk-access-status.ts diff --git a/src/main/ipc/developer-permissions.test.ts b/src/main/ipc/developer-permissions.test.ts index ccb119d0e5c..78c8d6e0be0 100644 --- a/src/main/ipc/developer-permissions.test.ts +++ b/src/main/ipc/developer-permissions.test.ts @@ -6,6 +6,7 @@ const { askForMediaAccessMock, getMediaAccessStatusMock, isTrustedAccessibilityClientMock, + getMacosFullDiskAccessStatusMock, execFileMock, createSocketMock, socketMock, @@ -28,6 +29,7 @@ const { askForMediaAccessMock: vi.fn(), getMediaAccessStatusMock: vi.fn(), isTrustedAccessibilityClientMock: vi.fn(), + getMacosFullDiskAccessStatusMock: vi.fn(), execFileMock: vi.fn(), createSocketMock: vi.fn(() => socketMock), socketMock, @@ -59,6 +61,10 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) +vi.mock('../macos-full-disk-access-status', () => ({ + getMacosFullDiskAccessStatus: getMacosFullDiskAccessStatusMock +})) + import { registerDeveloperPermissionHandlers } from './developer-permissions' describe('registerDeveloperPermissionHandlers', () => { @@ -72,6 +78,8 @@ describe('registerDeveloperPermissionHandlers', () => { askForMediaAccessMock.mockReset() getMediaAccessStatusMock.mockReset() isTrustedAccessibilityClientMock.mockReset() + getMacosFullDiskAccessStatusMock.mockReset() + getMacosFullDiskAccessStatusMock.mockResolvedValue('denied') execFileMock.mockReset() execFileMock.mockImplementation((...args: unknown[]) => { const callback = args.at(-1) @@ -119,6 +127,21 @@ describe('registerDeveloperPermissionHandlers', () => { return call[1] as (_event: unknown, args: { id: string }) => Promise } + it('returns the Full Disk Access read-probe status', async () => { + getMacosFullDiskAccessStatusMock.mockResolvedValue('granted') + registerDeveloperPermissionHandlers() + + const call = handleMock.mock.calls.find( + (registration: unknown[]) => registration[0] === 'developerPermissions:getStatus' + ) + const handler = call?.[1] as (() => Promise) | undefined + + await expect(handler?.()).resolves.toContainEqual({ + id: 'full-disk-access', + status: 'granted' + }) + }) + it('clears the local-network prompt fallback timer when UDP send settles first', async () => { registerDeveloperPermissionHandlers() diff --git a/src/main/ipc/developer-permissions.ts b/src/main/ipc/developer-permissions.ts index d0d079bd093..dc1171b8c7d 100644 --- a/src/main/ipc/developer-permissions.ts +++ b/src/main/ipc/developer-permissions.ts @@ -1,9 +1,7 @@ import { execFile } from 'node:child_process' import dgram from 'node:dgram' -import { access } from 'node:fs/promises' -import { homedir } from 'node:os' -import path from 'node:path' import { ipcMain, shell, systemPreferences } from 'electron' +import { getMacosFullDiskAccessStatus } from '../macos-full-disk-access-status' import type { DeveloperPermissionId, DeveloperPermissionRequestResult, @@ -50,21 +48,6 @@ function getMediaStatus(mediaType: 'microphone' | 'camera' | 'screen'): Develope } } -async function getFullDiskAccessStatus(): Promise { - const unsupported = unsupportedOffMac() - if (unsupported) { - return unsupported - } - try { - // Why: Safari bookmarks are TCC-protected, so read access is a practical - // Full Disk Access signal without touching user project contents. - await access(path.join(homedir(), 'Library', 'Safari', 'Bookmarks.plist')) - return 'granted' - } catch { - return 'unknown' - } -} - function getAccessibilityStatus(): DeveloperPermissionStatus { const unsupported = unsupportedOffMac() if (unsupported) { @@ -165,7 +148,7 @@ async function getPermissionState(id: DeveloperPermissionId): Promise { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) +}) + +describe('probeMacosFullDiskAccess', () => { + it('reports granted only when the TCC database opens for reading', async () => { + const readProbe = vi.fn().mockResolvedValue(undefined) + + await expect(probeMacosFullDiskAccess({ homeDirectory, readProbe })).resolves.toBe('granted') + expect(readProbe).toHaveBeenCalledWith(databasePath) + }) + + it.each(['EACCES', 'EPERM'])('reports denied for %s', async (code) => { + await expect( + probeMacosFullDiskAccess({ + homeDirectory, + readProbe: async () => { + throw fileSystemError(code) + } + }) + ).resolves.toBe('denied') + }) + + it.each(['ENOENT', 'ENOTDIR', 'EBUSY'])('keeps %s failures unknown', async (code) => { + await expect( + probeMacosFullDiskAccess({ + homeDirectory, + readProbe: async () => { + throw fileSystemError(code) + } + }) + ).resolves.toBe('unknown') + }) +}) + +describe('getMacosFullDiskAccessStatus', () => { + it('is unsupported off macOS', async () => { + Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' }) + + await expect(getMacosFullDiskAccessStatus()).resolves.toBe('unsupported') + }) +}) diff --git a/src/main/macos-full-disk-access-status.ts b/src/main/macos-full-disk-access-status.ts new file mode 100644 index 00000000000..aea18b95677 --- /dev/null +++ b/src/main/macos-full-disk-access-status.ts @@ -0,0 +1,44 @@ +import { open } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { DeveloperPermissionStatus } from '../shared/developer-permissions-types' + +type ReadProbe = (filePath: string) => Promise + +async function openForRead(filePath: string): Promise { + const handle = await open(filePath, 'r') + await handle.close() +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined +} + +export async function probeMacosFullDiskAccess({ + homeDirectory = homedir(), + readProbe = openForRead +}: { + homeDirectory?: string + readProbe?: ReadProbe +} = {}): Promise { + const databasePath = join( + homeDirectory, + 'Library', + 'Application Support', + 'com.apple.TCC', + 'TCC.db' + ) + try { + await readProbe(databasePath) + return 'granted' + } catch (error) { + const code = errorCode(error) + return code === 'EACCES' || code === 'EPERM' ? 'denied' : 'unknown' + } +} + +export async function getMacosFullDiskAccessStatus(): Promise { + return process.platform === 'darwin' ? probeMacosFullDiskAccess() : 'unsupported' +} diff --git a/src/main/macos-tcc-prompt-notice.test.ts b/src/main/macos-tcc-prompt-notice.test.ts index 1c268a7e240..e8745b67c61 100644 --- a/src/main/macos-tcc-prompt-notice.test.ts +++ b/src/main/macos-tcc-prompt-notice.test.ts @@ -30,6 +30,7 @@ vi.mock('node:fs', async (importOriginal) => ({ const { TCC_PROMPT_NOTICE_THRESHOLD, + TCC_PROMPT_NOTICE_VERSION, TCC_PROMPT_WATCH_START_FALLBACK_MS, acknowledgePendingTccPromptNotice, consumePendingTccPromptNotice, @@ -67,7 +68,11 @@ describe('tcc prompt notice threshold', () => { handleTccPromptForTests() expect(writeFileAtomically).toHaveBeenCalledTimes(1) const [, contents] = writeFileAtomically.mock.calls[0] as [string, string] - expect(JSON.parse(contents)).toMatchObject({ promptCount: 1, notified: false }) + expect(JSON.parse(contents)).toMatchObject({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, + promptCount: 1, + notified: false + }) }) it('never fires again once dismissed, even past the threshold', () => { @@ -303,9 +308,9 @@ describe('tcc prompt notice threshold', () => { }) it.each([ - { promptCount: 2, notified: false }, - { promptCount: 1, notified: true } - ])('requires a fresh detection for a legacy tally: $promptCount/$notified', (persisted) => { + { promptCount: 2, notified: false, acknowledgedAfterClose: false }, + { promptCount: 1, notified: true, acknowledgedAfterClose: true } + ])('re-arms a legacy tally after a fresh detection: $promptCount/$notified', (persisted) => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue(JSON.stringify({ ...persisted, dismissed: false })) @@ -329,11 +334,35 @@ describe('tcc prompt notice threshold', () => { } }) + it('preserves a legacy permanent opt-out', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) + readTallyFile.mockReturnValue( + JSON.stringify({ + promptCount: 1, + notified: true, + dismissed: true, + acknowledgedAfterClose: true + }) + ) + try { + const mainWindow = createWindowStub() + initTccPromptNotice(mainWindow as never) + + expect(watchStart).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + expect(consumePendingTccPromptNotice(1)).toBeNull() + } finally { + Object.defineProperty(process, 'platform', platform!) + } + }) + it('replays an unclosed notice from the new delivery contract', () => { const platform = Object.getOwnPropertyDescriptor(process, 'platform') Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue( JSON.stringify({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 1, notified: false, dismissed: false, @@ -359,6 +388,7 @@ describe('tcc prompt notice threshold', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' }) readTallyFile.mockReturnValue( JSON.stringify({ + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 1, notified: true, dismissed: false, diff --git a/src/main/macos-tcc-prompt-notice.ts b/src/main/macos-tcc-prompt-notice.ts index 9d7dea359d0..c51367d8c6a 100644 --- a/src/main/macos-tcc-prompt-notice.ts +++ b/src/main/macos-tcc-prompt-notice.ts @@ -14,6 +14,9 @@ import { MacosTccPromptWatch } from './macos-tcc-prompt-watch' /** Why: the first detected dialog identifies an affected user; the notice remains one-time. */ export const TCC_PROMPT_NOTICE_THRESHOLD = 1 +/** Why: re-arm users who may have trusted the former false-positive Full Disk Access badge. */ +export const TCC_PROMPT_NOTICE_VERSION = 2 + /** Why: preserve detection when Electron never emits `ready-to-show` without competing with startup. */ export const TCC_PROMPT_WATCH_START_FALLBACK_MS = 10_000 @@ -28,6 +31,7 @@ export type TccPromptNoticeClaim = TccPromptNoticePayload & { } type TccPromptTally = { + noticeVersion: number promptCount: number notified: boolean dismissed: boolean @@ -35,6 +39,7 @@ type TccPromptTally = { } const EMPTY_TALLY: TccPromptTally = { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: 0, notified: false, dismissed: false, @@ -57,15 +62,28 @@ function loadTally(): TccPromptTally { try { const parsed = JSON.parse(readFileSync(tallyPath(), 'utf-8')) as Partial const dismissed = parsed.dismissed === true - if (!dismissed && typeof parsed.acknowledgedAfterClose !== 'boolean') { - // Why: a legacy tally only proves a past prompt, not that Full Disk Access is still missing. + if (dismissed) { + return { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, + promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0, + notified: true, + dismissed: true, + acknowledgedAfterClose: true + } + } + if (parsed.noticeVersion !== TCC_PROMPT_NOTICE_VERSION) { + return { ...EMPTY_TALLY } + } + if (typeof parsed.acknowledgedAfterClose !== 'boolean') { + // Why: an incomplete tally proves only a past prompt, not that access is still missing. return { ...EMPTY_TALLY } } const acknowledgedAfterClose = parsed.acknowledgedAfterClose === true return { + noticeVersion: TCC_PROMPT_NOTICE_VERSION, promptCount: typeof parsed.promptCount === 'number' ? parsed.promptCount : 0, - notified: parsed.notified === true && (dismissed || acknowledgedAfterClose), - dismissed, + notified: parsed.notified === true && acknowledgedAfterClose, + dismissed: false, acknowledgedAfterClose } } catch { From f23b3308a5eb23855b4d7f4e676e1405568b4a99 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:54:40 -0700 Subject: [PATCH 02/17] fix(mobile): route external mouse click and drag to the terminal (#11473) * fix(mobile): route external mouse click and drag to the terminal The terminal WebView suppresses mousedown/click at capture so xterm's own mouse handling stays inert (its onData bytes are dropped by the mobile bridge). That left hardware mouse clicks and drags with no path at all: touch taps reached mouse-aware TUIs and drove selection, while a Bluetooth mouse or trackpad click did nothing (#8818; wheel half landed in #11247). Add a pointer-event router on the terminal surface (pointerType 'mouse', left button only) that mirrors touch semantics: - plain click: same pipeline as a touch tap (links/file paths first, then tracking-mode press+release reports, else keyboard focus), and a click on an active selection dismisses it like touch does - drag with mouse tracking: press at the anchor, per-cell motion reports (drag/any modes), release on pointerup or pointercancel - drag without tracking: character-anchored selection reusing the touch handle-drag plumbing (edge scroll, handles, copy pill) Widen the RN gesture-input grammar to pass left-drag motion reports (SGR button 32, default-encoding byte 64) through the existing validation and rate limiting. Mock server: echo the subscribe viewport and serialize scrollback so the session screen leaves the resubscribe loop, serve the session-tabs subscribe stream, and add a MOCK_TUI=1 mouse-tracking scenario plus a [SEND] byte log - the rig used to reproduce and verify this fix on an Android emulator. Fixes #8818 * fix(mobile): capture the mouse pointer and clear stale gestures on pointerdown A drag leaving the terminal surface dropped pointermove/pointerup without pointer capture, stranding the gesture; a pointerup lost outside the WebView could leave a tracked press latched until the next gesture. * fix(mobile): end mouse gestures whose pointerup never reached the surface Capture the mouse pointer on pointerdown so a drag that leaves the surface keeps delivering pointermove/pointerup; when capture is unavailable and the release is lost anyway, synthesize the release from the next buttons==0 pointermove or the next pointerdown, so a tracking TUI is never left with the left button latched down. * fix(mock-server): clear the terminal stream interval on resubscribe and unsubscribe * fix(mobile): synthesize the lost-pointerup release at the pointer's current cell * test(mobile): split terminal mouse click and drag coverage * test(mobile): satisfy changed-line quality checks * fix(mobile): cancel stale mock terminal callbacks * refactor(mobile): extract mouse report cell mapping --- mobile/scripts/mock-server-rpc-handlers.ts | 60 +---- .../mock-server-session-tabs-fixture.ts | 24 +- mobile/scripts/mock-server-terminal-stream.ts | 132 ++++++++++ ...erver-terminal-stream-cancellation.test.ts | 74 ++++++ .../terminal/terminal-gesture-input.test.ts | 16 ++ mobile/src/terminal/terminal-gesture-input.ts | 6 +- mobile/src/terminal/terminal-webview-html.ts | 33 +-- ...minal-webview-mouse-click-drag-injected.ts | 199 ++++++++++++++ .../terminal-webview-mouse-click.test.ts | 90 +++++++ .../terminal-webview-mouse-drag.test.ts | 87 ++++++ ...inal-webview-mouse-report-cell-injected.ts | 29 ++ .../terminal-webview-mouse-test-harness.ts | 248 ++++++++++++++++++ 12 files changed, 919 insertions(+), 79 deletions(-) create mode 100644 mobile/scripts/mock-server-terminal-stream.ts create mode 100644 mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-click.test.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-drag.test.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts create mode 100644 mobile/src/terminal/terminal-webview-mouse-test-harness.ts diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts index c5aebf054b4..5971b9dee94 100644 --- a/mobile/scripts/mock-server-rpc-handlers.ts +++ b/mobile/scripts/mock-server-rpc-handlers.ts @@ -13,11 +13,7 @@ import { handleMockGitRequest } from './mock-server-git-state' import { handleMockAccountRequest } from './mock-server-account-rpc' import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario' import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture' -import { - createMockTerminals, - FAKE_SCROLLBACK, - STREAMING_CHUNKS -} from './mock-server-terminal-fixtures' +import { handleMockTerminalRequest } from './mock-server-terminal-stream' import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario' const MOCK_REPO_COUNT = readScenarioNumber('MOCK_REPO_COUNT', 2) @@ -64,6 +60,8 @@ export type RpcResponse = { _meta: { runtimeId: string } } +export type RpcRespond = (response: RpcResponse, shouldSend?: () => boolean) => void + export const mockScenarioSummary = { repoCount: FAKE_REPOS.length, worktreeCount: fakeWorktrees.length, @@ -111,13 +109,18 @@ export function handleRequest( send: (response: RpcResponse) => void, ws: WebSocket ): void { - const respond = (response: RpcResponse) => { + const respond: RpcRespond = (response, shouldSend) => { + const deliver = () => { + if (shouldSend?.() !== false) { + send(response) + } + } const delay = responseDelayFor(request.method) if (delay > 0) { - setTimeout(() => send(response), delay) + setTimeout(deliver, delay) return } - send(response) + deliver() } // Each returns false for methods it does not own; first owner wins. @@ -126,7 +129,8 @@ export function handleRequest( handleMockFilePreviewRequest(request, respond, success, error) || handleMockAccountRequest(request, respond, success, error) || handleMockNativeChatRequest(request, respond, success, error, ws) || - handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) + handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) || + handleMockTerminalRequest(request, respond, success, ws, terminalListWorktreeId) ) { return } @@ -279,44 +283,6 @@ export function handleRequest( break } - case 'terminal.list': { - const terminals = createMockTerminals(terminalListWorktreeId(request.params?.worktree)) - respond( - success(request.id, { - terminals, - totalCount: terminals.length, - truncated: false - }) - ) - break - } - - case 'terminal.subscribe': { - respond(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false })) - - let chunkIndex = 0 - const interval = setInterval(() => { - if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) { - clearInterval(interval) - if (ws.readyState === ws.OPEN) { - respond(success(request.id, { type: 'end' })) - } - return - } - respond(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true)) - chunkIndex++ - }, 500) - break - } - - case 'terminal.send': - respond(success(request.id, { send: { handle: 'term-1', ok: true } })) - break - - case 'terminal.unsubscribe': - respond(success(request.id, { unsubscribed: true })) - break - case 'files.open': case 'files.openDiff': respond( diff --git a/mobile/scripts/mock-server-session-tabs-fixture.ts b/mobile/scripts/mock-server-session-tabs-fixture.ts index fbc94c2829e..f4a5bd1da1c 100644 --- a/mobile/scripts/mock-server-session-tabs-fixture.ts +++ b/mobile/scripts/mock-server-session-tabs-fixture.ts @@ -60,10 +60,24 @@ export function handleMockSessionTabsRequest( // absent or `id:`-prefixed selector means. resolveWorktreeId: (selector: unknown) => string | undefined ): boolean { - if (request.method !== 'session.tabs.list') { - return false + if (request.method === 'session.tabs.list') { + const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' + respond(success(request.id, createMockSessionTabs(worktreeId))) + return true } - const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' - respond(success(request.id, createMockSessionTabs(worktreeId))) - return true + if (request.method === 'session.tabs.subscribe') { + // Without a live stream the client's health loop keeps invalidating list + // fetches mid-flight (barrier bump on the failed probe), so the session + // screen never leaves 'Loading tabs'. snapshot then updated => 'live'. + const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' + const snapshot = createMockSessionTabs(worktreeId) + respond(success(request.id, { type: 'snapshot', ...snapshot }, true)) + respond(success(request.id, { type: 'updated', ...snapshot }, true)) + return true + } + if (request.method === 'session.tabs.unsubscribe') { + respond(success(request.id, { unsubscribed: true })) + return true + } + return false } diff --git a/mobile/scripts/mock-server-terminal-stream.ts b/mobile/scripts/mock-server-terminal-stream.ts new file mode 100644 index 00000000000..f747ec59b21 --- /dev/null +++ b/mobile/scripts/mock-server-terminal-stream.ts @@ -0,0 +1,132 @@ +import type { WebSocket } from 'ws' +import type { RpcRequest, RpcRespond, RpcResponse } from './mock-server-rpc-handlers' +import { + createMockTerminals, + FAKE_SCROLLBACK, + STREAMING_CHUNKS +} from './mock-server-terminal-fixtures' + +// Why: the client resubscribes on every viewport change; without cancellation +// each resubscribe would stack another interval streaming under a dead request. +type TerminalStream = { interval: ReturnType | null } +const terminalStreams = new WeakMap>() + +function clearTerminalStream(ws: WebSocket, terminal: string): void { + const perTerminal = terminalStreams.get(ws) + const stream = perTerminal?.get(terminal) + if (stream) { + stopTerminalStreamInterval(stream) + perTerminal?.delete(terminal) + } +} + +function beginTerminalStream(ws: WebSocket, terminal: string): TerminalStream { + clearTerminalStream(ws, terminal) + let perTerminal = terminalStreams.get(ws) + if (!perTerminal) { + perTerminal = new Map() + terminalStreams.set(ws, perTerminal) + } + const stream = { interval: null } + perTerminal.set(terminal, stream) + return stream +} + +function isCurrentTerminalStream(ws: WebSocket, terminal: string, stream: TerminalStream): boolean { + return terminalStreams.get(ws)?.get(terminal) === stream && ws.readyState === ws.OPEN +} + +function stopTerminalStreamInterval(stream: TerminalStream): void { + if (stream.interval !== null) { + clearInterval(stream.interval) + stream.interval = null + } +} + +/** Terminal list/stream/input backend for the mock server. Returns false for + * methods it does not own. */ +export function handleMockTerminalRequest( + request: RpcRequest, + respond: RpcRespond, + success: (id: string, result: unknown, streaming?: boolean) => RpcResponse, + ws: WebSocket, + // Shared with `session.tabs.list` so both surfaces agree on which worktree an + // absent or `id:`-prefixed selector means. + resolveWorktreeId: (selector: unknown) => string | undefined +): boolean { + switch (request.method) { + case 'terminal.list': { + const terminals = createMockTerminals(resolveWorktreeId(request.params?.worktree)) + respond( + success(request.id, { + terminals, + totalCount: terminals.length, + truncated: false + }) + ) + return true + } + + case 'terminal.subscribe': { + const terminal = String(request.params?.terminal ?? 'term-1') + const stream = beginTerminalStream(ws, terminal) + const isCurrent = () => isCurrentTerminalStream(ws, terminal, stream) + // Why: the client resubscribes until scrollback echoes its viewport dims; + // the legacy `lines` shape left the session screen in that loop forever. + const viewport = request.params?.viewport as { cols?: number; rows?: number } | undefined + // MOCK_TUI=1 arms SGR drag mouse tracking (1002/1006) inside the scrollback + // itself so every xterm re-init re-enters the mode - used by mouse/touch + // input repros (#8818). + const tuiPreamble = + process.env.MOCK_TUI === '1' + ? '\x1b[?1002h\x1b[?1006h[mock] mouse tracking ON (1002/1006)\r\n' + : '' + respond( + success(request.id, { + type: 'scrollback', + cols: viewport?.cols ?? 80, + rows: viewport?.rows ?? 24, + serialized: FAKE_SCROLLBACK.replace(/\n/g, '\r\n') + tuiPreamble, + truncated: false + }), + isCurrent + ) + + let chunkIndex = 0 + stream.interval = setInterval(() => { + if (!isCurrent()) { + stopTerminalStreamInterval(stream) + return + } + if (chunkIndex >= STREAMING_CHUNKS.length) { + // Why: no `end` event - a live terminal stream stays open, and `end` + // makes the client tear the subscription down and blank the pane. + stopTerminalStreamInterval(stream) + return + } + respond( + success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true), + isCurrent + ) + chunkIndex++ + }, 500) + return true + } + + case 'terminal.send': + // Input-routing repros (#8818) assert on the exact bytes reaching the host. + console.log( + `[SEND] terminal=${String(request.params?.terminal)} text=${JSON.stringify(request.params?.text)}` + ) + respond(success(request.id, { send: { handle: 'term-1', ok: true } })) + return true + + case 'terminal.unsubscribe': + clearTerminalStream(ws, String(request.params?.terminal ?? 'term-1')) + respond(success(request.id, { unsubscribed: true })) + return true + + default: + return false + } +} diff --git a/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts b/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts new file mode 100644 index 00000000000..66e4900fe7c --- /dev/null +++ b/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WebSocket } from 'ws' +import { handleMockTerminalRequest } from '../../scripts/mock-server-terminal-stream' +import { + handleRequest, + type RpcRequest, + type RpcRespond, + type RpcResponse +} from '../../scripts/mock-server-rpc-handlers' + +type DeferredResponse = { + response: RpcResponse + shouldSend?: () => boolean +} + +function success(id: string, result: unknown, streaming?: boolean): RpcResponse { + return { + id, + ok: true, + result, + ...(streaming ? { streaming: true as const } : {}), + _meta: { runtimeId: 'test' } + } +} + +function request(id: string, method: string): RpcRequest { + return { id, method, params: { terminal: 'term-1', viewport: { cols: 80, rows: 24 } } } +} + +describe('mock terminal stream cancellation', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + }) + + it('invalidates delayed frames after resubscribe and unsubscribe', () => { + vi.useFakeTimers() + const ws = { OPEN: 1, readyState: 1 } as unknown as WebSocket + const deferred: DeferredResponse[] = [] + const respond: RpcRespond = (response, shouldSend) => { + deferred.push({ response, shouldSend }) + } + const handle = (rpcRequest: RpcRequest) => + handleMockTerminalRequest(rpcRequest, respond, success, ws, () => 'wt') + + expect(handle(request('old', 'terminal.subscribe'))).toBe(true) + vi.advanceTimersByTime(500) + expect(deferred.filter(({ response }) => response.id === 'old')).toHaveLength(2) + + expect(handle(request('new', 'terminal.subscribe'))).toBe(true) + const oldResponses = deferred.filter(({ response }) => response.id === 'old') + const newResponses = deferred.filter(({ response }) => response.id === 'new') + expect(oldResponses.every(({ shouldSend }) => shouldSend?.() === false)).toBe(true) + expect(newResponses.every(({ shouldSend }) => shouldSend?.() === true)).toBe(true) + + expect(handle(request('stop', 'terminal.unsubscribe'))).toBe(true) + expect(newResponses.every(({ shouldSend }) => shouldSend?.() === false)).toBe(true) + }) + + it('drops queued delayed frames after stream cancellation', () => { + vi.useFakeTimers() + vi.stubEnv('MOCK_RPC_DELAY_TERMINAL_SUBSCRIBE_MS', '1000') + const ws = { OPEN: 1, readyState: 1 } as unknown as WebSocket + const send = vi.fn<(response: RpcResponse) => void>() + + handleRequest(request('old', 'terminal.subscribe'), send, ws) + vi.advanceTimersByTime(500) + handleRequest(request('new', 'terminal.subscribe'), send, ws) + handleRequest(request('stop', 'terminal.unsubscribe'), send, ws) + + vi.advanceTimersByTime(1000) + expect(send.mock.calls.map(([response]) => response.id)).toEqual(['stop']) + }) +}) diff --git a/mobile/src/terminal/terminal-gesture-input.test.ts b/mobile/src/terminal/terminal-gesture-input.test.ts index a120d569db9..a920f37a7a3 100644 --- a/mobile/src/terminal/terminal-gesture-input.test.ts +++ b/mobile/src/terminal/terminal-gesture-input.test.ts @@ -27,6 +27,22 @@ describe('isTerminalGestureInput', () => { expect(isTerminalGestureInput(`${ESC}[<0;9999;9999M${ESC}[<0;9999;9999m`)).toBe(true) }) + it('accepts SGR left-drag motion sequences but rejects a motion release', () => { + expect(isTerminalGestureInput(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(true) + expect(countTerminalGestureInputSequences(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(2) + expect(isTerminalGestureInput(`${ESC}[<0;10;5M${ESC}[<32;11;5M${ESC}[<0;11;5m`)).toBe(true) + expect(isTerminalGestureInput(`${ESC}[<32;10;5m`)).toBe(false) + }) + + it('accepts default-encoding left-drag motion sequences', () => { + expect(isTerminalGestureInput(`${ESC}[M${String.fromCharCode(64, 43, 38)}`)).toBe(true) + expect( + isTerminalGestureInput( + `${ESC}[M${String.fromCharCode(32, 33, 33)}${ESC}[M${String.fromCharCode(64, 34, 33)}${ESC}[M${String.fromCharCode(35, 34, 33)}` + ) + ).toBe(true) + }) + it('accepts repeated default mouse wheel sequences', () => { expect( isTerminalGestureInput( diff --git a/mobile/src/terminal/terminal-gesture-input.ts b/mobile/src/terminal/terminal-gesture-input.ts index 6b84c4824fe..6af8a2367f9 100644 --- a/mobile/src/terminal/terminal-gesture-input.ts +++ b/mobile/src/terminal/terminal-gesture-input.ts @@ -1,8 +1,9 @@ const ESC = '\x1b' const MAX_TERMINAL_GESTURE_INPUT_LENGTH = 2048 const MAX_TERMINAL_GESTURE_INPUT_SEQUENCES = 32 +// Buttons: 0 left press/release, 32 left-drag motion, 64/65 wheel. const SGR_MOUSE_GESTURE_SEQUENCE_RE = new RegExp( - `^${ESC}\\[<(0|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$` + `^${ESC}\\[<(0|32|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$` ) function isDefaultMouseGestureSequence(bytes: string, offset: number): number | null { @@ -12,8 +13,9 @@ function isDefaultMouseGestureSequence(bytes: string, offset: number): number | const button = bytes.charCodeAt(offset + 3) const col = bytes.charCodeAt(offset + 4) const row = bytes.charCodeAt(offset + 5) + // Buttons: 32 left press, 35 release, 64 left-drag motion, 96/97 wheel. if ( - (button === 32 || button === 35 || button === 96 || button === 97) && + (button === 32 || button === 35 || button === 64 || button === 96 || button === 97) && col >= 33 && col <= 126 && row >= 33 && diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index e8409cb3bee..a7715e4f564 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -11,6 +11,8 @@ import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' +import { TERMINAL_MOUSE_CLICK_DRAG_JS } from './terminal-webview-mouse-click-drag-injected' +import { TERMINAL_MOUSE_REPORT_CELL_JS } from './terminal-webview-mouse-report-cell-injected' import { TERMINAL_WHEEL_SCROLL_JS } from './terminal-webview-wheel-scroll-injected' const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { @@ -1153,31 +1155,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} return { col: col, row: viewportRow + viewportY }; } - function viewportToMouseReportCell(clientX, clientY) { - if (!term) return null; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW <= 0 || cellH <= 0) return null; - if (typeof clientX !== 'number') clientX = window.innerWidth / 2; - if (typeof clientY !== 'number') clientY = window.innerHeight / 2; - var total = getTotalScale(); - if (total <= 0) total = 1; - var sx = (clientX - panX) / total; - var sy = (clientY - panY) / total; - var maxX = Math.max(0, term.cols * cellW - 1); - var maxY = Math.max(0, term.rows * cellH - 1); - if (sx < 0) sx = 0; - if (sx > maxX) sx = maxX; - if (sy < 0) sy = 0; - if (sy > maxY) sy = maxY; - var col = Math.floor(sx / cellW); - var row = Math.floor(sy / cellH); - if (col < 0) col = 0; - if (col > term.cols - 1) col = term.cols - 1; - if (row < 0) row = 0; - if (row > term.rows - 1) row = term.rows - 1; - return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; - } + ${TERMINAL_MOUSE_REPORT_CELL_JS} function isAlternateBufferActive() { try { @@ -1648,6 +1626,10 @@ ${TERMINAL_WEBGL_RECOVERY_JS} // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). ${TERMINAL_WHEEL_SCROLL_JS} + // External mouse click/drag: see + // terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines). + ${TERMINAL_MOUSE_CLICK_DRAG_JS} + btnCopy.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); @@ -1705,6 +1687,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true); attachSurfaceWheelHandler(targetSurface); + attachSurfaceMouseClickDragHandler(targetSurface); targetSurface.addEventListener('touchstart', function(e) { if (dispatcherShouldBlockSurface()) return; diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts new file mode 100644 index 00000000000..bc9c12aa016 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts @@ -0,0 +1,199 @@ +// Indirect-pointer (external mouse / trackpad) click and drag for the terminal +// surface, injected into XTERM_HTML. Extracted from terminal-webview-html.ts to +// keep that file within its max-lines budget. Companion to +// terminal-webview-wheel-scroll-injected.ts, which owns the wheel half (#11247); +// this owns the click/drag half of #8818. Closes over host-IIFE state/functions: +// term, ESC, sel, selMode, selectionOverlay, TAP_SLOP, getMouseTrackingMode, +// viewportToCell, viewportToMouseReportCell, isSafeSgrMouseCoordinate, +// sgrMouseMode, sgrMousePixelsMode, notify, notifyTerminalSurfaceTap, +// cancelSelect, applyXtermSelection, repositionOverlay, handleDragMove, +// stopEdgeScroll, and dispatcherShouldBlockSurface. +// +// Why pointer events: a hardware mouse on Android/iPadOS raises pointer events +// with pointerType 'mouse' and NO touch events, while a finger raises +// pointerType 'touch' plus the touch events the document dispatcher owns. The +// capture-phase mousedown/click suppression in attachSurfaceEventHandlers stays: +// it is what keeps xterm's own mouse handling inert (its onData output is +// dropped by the mobile bridge), and pointer events are unaffected by it. +export const TERMINAL_MOUSE_CLICK_DRAG_JS = ` + var mouseGesture = null; + + // One report per transition, built with the same encoding ladder as + // buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns '' + // when the mode does not report this transition (x10 has no release, only + // drag/any report motion) or the cell is not encodable. + function buildMouseButtonReport(kind, clientX, clientY) { + var mouseTrackingMode = getMouseTrackingMode(); + if (mouseTrackingMode === 'none') return ''; + if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return ''; + if (kind === 'release' && mouseTrackingMode === 'x10') return ''; + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + var sgrButton = kind === 'motion' ? 32 : 0; + var sgrFinal = kind === 'release' ? 'm' : 'M'; + if (sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; + return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal; + } + var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32; + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path; drop instead of corrupting input. + if (col > 126 || row > 126) return ''; + return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function mouseReportCellKey(clientX, clientY) { + var cell = viewportToMouseReportCell(clientX, clientY); + return cell ? cell.col + ',' + cell.row : null; + } + + function abandonMouseGesture() { + var gesture = mouseGesture; + mouseGesture = null; + if (!gesture) return; + if (gesture.mode === 'tracking') { + // Why: the press report already went to the TUI; a lost pointer must not + // leave the button latched down on the far side. + var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY); + if (release) notify({ type: 'terminal-input', bytes: release }); + } else if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + } + } + + function beginMouseDrag(gesture) { + gesture.moved = true; + if (getMouseTrackingMode() !== 'none') { + gesture.mode = 'tracking'; + gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY); + var press = buildMouseButtonReport('press', gesture.startX, gesture.startY); + if (press) notify({ type: 'terminal-input', bytes: press }); + return; + } + var anchor = viewportToCell(gesture.startX, gesture.startY); + if (!anchor) { + gesture.mode = 'cancelled'; + return; + } + // Why: mouse drags select character-anchored ranges like desktop terminals, + // not the word-seeded long-press selection; reuse the touch handle-drag + // plumbing (edge scroll included) by acting as a live 'end' handle. + gesture.mode = 'selecting'; + selMode = 'select'; + sel = { anchor: anchor, focus: anchor, activeHandle: 'end' }; + selectionOverlay.classList.add('active'); + notify({ type: 'set-select-mode', enabled: true }); + applyXtermSelection(); + repositionOverlay(); + } + + function attachSurfaceMouseClickDragHandler(targetSurface) { + targetSurface.addEventListener('pointerdown', function(e) { + if (e.pointerType !== 'mouse' || e.button !== 0) return; + if (dispatcherShouldBlockSurface() || !term) return; + // Why: a pointerup lost outside the WebView must not leave the previous + // gesture latched (tracking press with no release) when the next one lands. + if (mouseGesture) abandonMouseGesture(); + // Why: mouse pointers have no implicit capture; without it a drag that + // leaves the surface drops pointermove/pointerup and strands the gesture. + try { + if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId); + } catch (err) {} + mouseGesture = { + startX: e.clientX, startY: e.clientY, + lastX: e.clientX, lastY: e.clientY, + lastCellKey: null, + moved: false, + mode: 'pending', + dismissedSelection: false + }; + if (selMode === 'select') { + // Why: touch parity — pressing outside the pill dismisses the current + // selection; the same press may still start a new drag selection. + cancelSelect(); + mouseGesture.dismissedSelection = true; + } + }, true); + + targetSurface.addEventListener('pointermove', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return; + if (!term) return; + gesture.lastX = e.clientX; + gesture.lastY = e.clientY; + if ((e.buttons & 1) === 0) { + // Why: a pointerup lost outside the WebView (capture unavailable) must + // end the gesture here, or a tracked press stays latched at the TUI. + // Coordinates first, so the synthesized release lands where the + // pointer re-entered rather than at the previous cell. + abandonMouseGesture(); + return; + } + if (!gesture.moved) { + var dx = Math.abs(e.clientX - gesture.startX); + var dy = Math.abs(e.clientY - gesture.startY); + if (dx + dy <= TAP_SLOP) return; + beginMouseDrag(gesture); + } + if (gesture.mode === 'tracking') { + // Why: one motion report per cell keeps drags bounded by grid size, not + // by pointermove cadence, so the RN rate limiter is never the bottleneck. + var cellKey = mouseReportCellKey(e.clientX, e.clientY); + if (cellKey && cellKey !== gesture.lastCellKey) { + gesture.lastCellKey = cellKey; + var motion = buildMouseButtonReport('motion', e.clientX, e.clientY); + if (motion) notify({ type: 'terminal-input', bytes: motion }); + } + } else if (gesture.mode === 'selecting') { + handleDragMove('end', e.clientX, e.clientY); + } + }, true); + + targetSurface.addEventListener('pointerup', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return; + mouseGesture = null; + if (gesture.mode === 'cancelled' || !term) return; + if (gesture.mode === 'tracking') { + var release = buildMouseButtonReport('release', e.clientX, e.clientY); + if (release) notify({ type: 'terminal-input', bytes: release }); + return; + } + if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + repositionOverlay(); + return; + } + if (dispatcherShouldBlockSurface()) return; + // Why: a dismissing tap only clears the selection (touch parity); it must + // not also open a link or focus the keyboard underneath. + if (gesture.dismissedSelection) return; + // Plain click == touch tap: links and file paths win, then tracking-mode + // click reports, then keyboard focus. + notifyTerminalSurfaceTap(e.clientX, e.clientY); + }, true); + + targetSurface.addEventListener('pointercancel', function(e) { + if (e.pointerType !== 'mouse') return; + abandonMouseGesture(); + }, true); + + // Why: Android input injection can pair a mouse-flavored pointerdown with + // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives, + // the document touch dispatcher owns the gesture. + targetSurface.addEventListener('touchstart', function() { + if (mouseGesture) abandonMouseGesture(); + }, true); + } +` diff --git a/mobile/src/terminal/terminal-webview-mouse-click.test.ts b/mobile/src/terminal/terminal-webview-mouse-click.test.ts new file mode 100644 index 00000000000..bb2494b62e7 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-click.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { ESC, useTerminalMouseWebViewHarness } from './terminal-webview-mouse-test-harness' + +describe('terminal WebView external mouse click', () => { + const mouse = useTerminalMouseWebViewHarness() + + it('reports a mouse click to a click-tracking TUI the way a touch tap does (#8818)', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.mouseClick(40, 60) + + // Default (non-SGR) encoding: press (32=' ') then release (35='#'), each + // ESC [ M btn col row. Cell bytes depend on the fit scale, not on routing. + const bytes = mouse.terminalInputBytes() + expect(bytes).toHaveLength(12) + expect(bytes.slice(0, 4)).toBe(`${ESC}[M `) + expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`) + expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12)) + }) + + it('dismisses an existing selection with a click without focusing the keyboard', () => { + mouse.boot() + mouse.mouseDrag(40, 60, 160, 90) + mouse.clearPostedMessages() + + mouse.mouseClick(240, 200) + + const messages = mouse.postedMessages() + expect(messages.filter((message) => message.type === 'set-select-mode')).toEqual([ + { type: 'set-select-mode', enabled: false } + ]) + expect(messages.filter((message) => message.type === 'terminal-tap')).toEqual([]) + expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(false) + }) + + it('routes a plain mouse click to the tap pipeline for keyboard focus', () => { + mouse.boot() + + mouse.mouseClick(40, 60) + + const messages = mouse.postedMessages() + expect(messages.filter((message) => message.type === 'terminal-tap')).toHaveLength(1) + expect(mouse.terminalInputBytes()).toBe('') + }) + + it('ignores non-mouse pointers and non-left buttons', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.dispatchPointer('pointerdown', { + pointerType: 'touch', + x: 40, + y: 60, + button: 0, + buttons: 1 + }) + mouse.dispatchPointer('pointerup', { + pointerType: 'touch', + x: 40, + y: 60, + button: 0, + buttons: 0 + }) + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 2, buttons: 2 }) + mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 2, buttons: 0 }) + + expect(mouse.terminalInputBytes()).toBe('') + expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([]) + }) + + it('lets the touch dispatcher own a gesture when touch events follow pointerdown', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + const touchStart = new Event('touchstart', { bubbles: true }) + // Why: the document tap dispatcher reads touches[0]; happy-dom's plain Event lacks it. + Object.defineProperty(touchStart, 'touches', { + value: [{ identifier: 7, clientX: 40, clientY: 60 }] + }) + mouse.terminalSurface().dispatchEvent(touchStart) + mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 0, buttons: 0 }) + + // Why: Android SOURCE_MOUSE injections can pair a mouse pointerdown with + // real touch events; double-handling would double the click report. + expect(mouse.terminalInputBytes()).toBe('') + }) +}) diff --git a/mobile/src/terminal/terminal-webview-mouse-drag.test.ts b/mobile/src/terminal/terminal-webview-mouse-drag.test.ts new file mode 100644 index 00000000000..e4fac512873 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-drag.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { + DEFAULT_MOUSE_REPORT_RE, + ESC, + useTerminalMouseWebViewHarness +} from './terminal-webview-mouse-test-harness' + +describe('terminal WebView external mouse drag', () => { + const mouse = useTerminalMouseWebViewHarness() + + it('sends press, per-cell motion, and release for a drag-tracking mouse drag', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.mouseDrag(40, 60, 160, 60) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + expect(reports.length).toBeGreaterThanOrEqual(3) + expect(reports[0]?.charCodeAt(3)).toBe(32) + for (const motion of reports.slice(1, -1)) { + expect(motion.charCodeAt(3)).toBe(64) + } + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + // Motion reports are deduped per cell, so a horizontal drag advances columns. + const motionCols = reports.slice(1, -1).map((report) => report.charCodeAt(4)) + expect(new Set(motionCols).size).toBe(motionCols.length) + }) + + it('does not report motion or release for an x10 click-only TUI drag', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'x10' + + mouse.mouseDrag(40, 60, 160, 60) + + // x10 reports presses only; the press goes out at drag start, no motion or release follows. + const bytes = mouse.terminalInputBytes() + expect(bytes.slice(0, 4)).toBe(`${ESC}[M `) + expect(bytes).toHaveLength(6) + }) + + it('selects character-anchored text on a mouse drag outside tracking mode', () => { + mouse.boot() + // Why: init itself emits a set-select-mode reset; only the drag matters here. + mouse.clearPostedMessages() + + mouse.mouseDrag(40, 60, 160, 90) + + expect(mouse.terminalInputBytes()).toBe('') + expect(mouse.selectionSpy()).toHaveBeenCalled() + expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(true) + const modes = mouse.postedMessages().filter((message) => message.type === 'set-select-mode') + expect(modes).toEqual([{ type: 'set-select-mode', enabled: true }]) + }) + + it('releases a tracked drag when the button state shows the pointerup was lost', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 }) + // Pointer re-enters with the button already up — the pointerup never arrived. + mouse.dispatchPointer('pointermove', { x: 200, y: 60, button: 0, buttons: 0 }) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + + mouse.clearPostedMessages() + mouse.dispatchPointer('pointermove', { x: 240, y: 60, button: 0, buttons: 1 }) + + // The lost pointerup ended the gesture; a later unrelated move must not emit motion. + expect(mouse.terminalInputBytes()).toBe('') + }) + + it('releases a tracked drag when the pointer is cancelled mid-gesture', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointercancel', { x: 160, y: 60 }) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + // Press went to the TUI, so the cancel must not leave the button latched. + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts b/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts new file mode 100644 index 00000000000..14baeeaf325 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts @@ -0,0 +1,29 @@ +// Mouse-report coordinate mapping injected into XTERM_HTML. Closes over term, +// panX/panY, getCellWidth/Height, and getTotalScale. +export const TERMINAL_MOUSE_REPORT_CELL_JS = ` + function viewportToMouseReportCell(clientX, clientY) { + if (!term) return null; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + if (cellW <= 0 || cellH <= 0) return null; + if (typeof clientX !== 'number') clientX = window.innerWidth / 2; + if (typeof clientY !== 'number') clientY = window.innerHeight / 2; + var total = getTotalScale(); + if (total <= 0) total = 1; + var sx = (clientX - panX) / total; + var sy = (clientY - panY) / total; + var maxX = Math.max(0, term.cols * cellW - 1); + var maxY = Math.max(0, term.rows * cellH - 1); + if (sx < 0) sx = 0; + if (sx > maxX) sx = maxX; + if (sy < 0) sy = 0; + if (sy > maxY) sy = maxY; + var col = Math.floor(sx / cellW); + var row = Math.floor(sy / cellH); + if (col < 0) col = 0; + if (col > term.cols - 1) col = term.cols - 1; + if (row < 0) row = 0; + if (row > term.rows - 1) row = term.rows - 1; + return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; + } +` diff --git a/mobile/src/terminal/terminal-webview-mouse-test-harness.ts b/mobile/src/terminal/terminal-webview-mouse-test-harness.ts new file mode 100644 index 00000000000..29500d3c710 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-test-harness.ts @@ -0,0 +1,248 @@ +import { runInThisContext } from 'node:vm' +import { afterEach, beforeEach, vi, type Mock } from 'vitest' +import { XTERM_HTML } from './terminal-webview-html' + +function iifeSource(): string { + const start = XTERM_HTML.indexOf('(function() {') + const end = XTERM_HTML.lastIndexOf('})();') + return XTERM_HTML.slice(start, end + '})();'.length) +} + +function bodyMarkup(): string { + const start = XTERM_HTML.indexOf('') + ''.length + const end = XTERM_HTML.indexOf('