Merge origin/main (browser copy, TOC, mobile IME, i18n, Windows startup) + add version_suffix input to release-cut

rc kind can now append an extra prerelease identifier (rc.N.perf):
sorts above its base rc.N but below rc.N+1, so perf-branch RCs are
identifiable by clients without outranking the main RC series.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-03 20:05:49 -04:00
co-authored by Orca
239 changed files with 11988 additions and 2414 deletions
+1 -18
View File
@@ -9,22 +9,8 @@ on:
- 'mobile-android-v*'
workflow_dispatch:
inputs:
bump_patch_version:
description: 'Bump the mobile marketing version patch number before release'
required: false
default: false
type: boolean
release_version:
description: 'Optional exact mobile marketing version override, e.g. 0.0.15'
required: false
type: string
bump_android_version_code:
description: 'Increment the Android versionCode before release'
required: false
default: false
type: boolean
android_version_code:
description: 'Optional exact Android versionCode override'
description: 'Optional exact mobile marketing version assertion, e.g. 0.0.22'
required: false
type: string
publish_github_release:
@@ -69,9 +55,6 @@ jobs:
id: release
env:
MOBILE_ANDROID_RELEASE_VERSION: ${{ github.event.inputs.release_version }}
MOBILE_ANDROID_BUMP_PATCH_VERSION: ${{ github.event.inputs.bump_patch_version }}
MOBILE_ANDROID_VERSION_CODE: ${{ github.event.inputs.android_version_code }}
MOBILE_ANDROID_BUMP_VERSION_CODE: ${{ github.event.inputs.bump_android_version_code }}
MOBILE_ANDROID_PUBLISH_RELEASE: ${{ github.event.inputs.publish_github_release }}
run: node scripts/prepare-android-release.mjs
+22 -10
View File
@@ -1,6 +1,6 @@
name: Cut Release
# Why: single entry point for manually and scheduled cutting releases.
# Why: single entry point for manually cutting releases.
# Replaces the old local `pnpm release:*` scripts and the standalone scheduled
# RC workflow so releases are always reproducible from CI and can never be
# accidentally tagged against an uncommitted or non-main working tree.
@@ -37,16 +37,15 @@ on:
type: string
default: main
dry_run:
description: Validate a scheduled-RC run without creating a tag
description: Validate an RC release cut without creating a tag
required: false
default: false
type: boolean
schedule:
# Why: GitHub scheduled workflows are not guaranteed to start exactly on
# time and can be delayed or dropped around high-load periods. We retry
# across the full target hour and dedupe per PT slot. Cron is UTC, so run
# during both PST and PDT equivalents and let the PT-hour gate decide.
- cron: '*/5 10,11,22,23 * * *'
version_suffix:
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only.
required: false
type: string
default: ''
permissions:
contents: write
@@ -182,8 +181,8 @@ jobs:
- name: Publish complete release-cut RC drafts from prior runs
id: publish_drafts
# Why: this must run even when the current slot already cut a tag; a
# later cron retry may be the first chance to unstick a complete RC draft.
# Why: a manual RC dispatch should unstick any complete RC draft before
# deciding whether to cut another tag.
if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -198,6 +197,7 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
run: |
set -euo pipefail
@@ -372,6 +372,18 @@ jobs:
fi
new="${base}-rc.$((highest_rc + 1))"
fi
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
# Why a dot-appended identifier (rc.N.perf): it sorts just
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
# branch builds never outrank the main RC series and cannot
# hijack the update channel; clients find them by matching the
# identifier ("perf") in the prerelease components.
if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then
echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2
exit 1
fi
new="${new}.${VERSION_SUFFIX}"
fi
;;
patch|minor|major)
new="$(bump "$latest_stable" "$KIND")"
+3
View File
@@ -60,6 +60,9 @@ pnpm-debug.log*
# Coverage
coverage/
# Benchmark run output
/tools/benchmarks/results/*.json
# Temp
tmp/
.tmp/
+82 -4
View File
@@ -6,6 +6,51 @@ const API_VERSION = '2022-11-28'
const MAX_RELEASE_BODY_LENGTH = 120_000
const TRUNCATION_NOTICE =
'\n\n---\nRelease notes were truncated because GitHub release bodies are limited to 125,000 characters.'
const DESKTOP_RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/
export function parseDesktopReleaseTag(tag) {
const match = DESKTOP_RELEASE_TAG_PATTERN.exec(tag)
if (!match) {
return null
}
return {
tag,
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
rc: match[4] === undefined ? null : Number(match[4])
}
}
function compareDesktopReleaseTags(a, b) {
const versionDiff = a.major - b.major || a.minor - b.minor || a.patch - b.patch
if (versionDiff !== 0) {
return versionDiff
}
if (a.rc === b.rc) {
return 0
}
if (a.rc === null) {
return 1
}
if (b.rc === null) {
return -1
}
return a.rc - b.rc
}
export function latestPreviousDesktopReleaseTag(tags, tag) {
const current = parseDesktopReleaseTag(tag)
if (!current) {
return ''
}
const previousTags = tags
.map((candidate) => parseDesktopReleaseTag(candidate))
.filter((candidate) => candidate && candidate.tag !== current.tag)
.filter((candidate) => compareDesktopReleaseTags(candidate, current) < 0)
.sort(compareDesktopReleaseTags)
return previousTags.at(-1)?.tag ?? ''
}
function githubHeaders(token) {
return {
@@ -30,6 +75,29 @@ async function githubJson(fetchImpl, url, token, options = {}) {
return res.json()
}
async function fetchRepoTags(repo, token, fetchImpl) {
const tags = []
for (let page = 1; ; page += 1) {
const pageTags = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/tags?per_page=100&page=${page}`,
token
)
if (!Array.isArray(pageTags)) {
throw new Error(`GitHub tags response page ${page} for ${repo} was not an array`)
}
for (const tag of pageTags) {
if (typeof tag?.name === 'string') {
tags.push(tag.name)
}
}
if (pageTags.length < 100) {
break
}
}
return tags
}
export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) {
if (body.length <= maxLength) {
return body
@@ -60,16 +128,26 @@ export async function createDraftRelease({
throw new Error('token is required')
}
const previousTag = latestPreviousDesktopReleaseTag(
await fetchRepoTags(repo, token, fetchImpl),
tag
)
const generateNotesBody = {
tag_name: tag,
target_commitish: tag,
...(previousTag ? { previous_tag_name: previousTag } : {})
}
// Why: draft releases are invisible to GitHub's generate-notes baseline.
// Passing the previous desktop tag keeps each release from accumulating
// notes from older drafts.
const releaseNotes = await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases/generate-notes`,
token,
{
method: 'POST',
body: JSON.stringify({
tag_name: tag,
target_commitish: tag
})
body: JSON.stringify(generateNotesBody)
}
)
+118 -5
View File
@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { createDraftRelease, truncateReleaseBody } from './create-draft-release.mjs'
import {
createDraftRelease,
latestPreviousDesktopReleaseTag,
parseDesktopReleaseTag,
truncateReleaseBody
} from './create-draft-release.mjs'
function jsonResponse(body, init = {}) {
return {
@@ -24,10 +29,60 @@ describe('truncateReleaseBody', () => {
})
})
describe('parseDesktopReleaseTag', () => {
it('parses stable and rc desktop release tags only', () => {
expect(parseDesktopReleaseTag('v1.4.36')).toMatchObject({
tag: 'v1.4.36',
major: 1,
minor: 4,
patch: 36,
rc: null
})
expect(parseDesktopReleaseTag('v1.4.36-rc.2')).toMatchObject({
tag: 'v1.4.36-rc.2',
major: 1,
minor: 4,
patch: 36,
rc: 2
})
expect(parseDesktopReleaseTag('mobile-v0.0.12')).toBeNull()
})
})
describe('latestPreviousDesktopReleaseTag', () => {
it('bounds stable notes to the previous rc when one exists', () => {
expect(latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36-rc.0', 'v1.4.36'], 'v1.4.36')).toBe(
'v1.4.36-rc.0'
)
})
it('bounds the first rc notes to the previous stable release', () => {
expect(
latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36-rc.0', 'mobile-v0.0.12'], 'v1.4.36-rc.0')
).toBe('v1.4.35')
})
it('bounds later rc notes to the prior rc', () => {
expect(latestPreviousDesktopReleaseTag(['v1.4.36-rc.0', 'v1.4.36-rc.1'], 'v1.4.36-rc.1')).toBe(
'v1.4.36-rc.0'
)
})
it('returns empty string for the first desktop release when no earlier tag exists', () => {
expect(latestPreviousDesktopReleaseTag(['v1.4.36', 'mobile-v0.0.12'], 'v1.4.36')).toBe('')
expect(latestPreviousDesktopReleaseTag([], 'v1.4.36')).toBe('')
})
it('returns empty string when the current tag is not a desktop release tag', () => {
expect(latestPreviousDesktopReleaseTag(['v1.4.35', 'v1.4.36'], 'mobile-v0.0.12')).toBe('')
})
})
describe('createDraftRelease', () => {
it('creates a draft release with bounded generated notes', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.35' }, { name: 'v1.4.36' }]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
@@ -41,17 +96,23 @@ describe('createDraftRelease', () => {
expect(fetchImpl).toHaveBeenNthCalledWith(
1,
'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=1',
expect.any(Object)
)
expect(fetchImpl).toHaveBeenNthCalledWith(
2,
'https://api.github.com/repos/stablyai/orca/releases/generate-notes',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
tag_name: 'v1.4.36',
target_commitish: 'v1.4.36'
target_commitish: 'v1.4.36',
previous_tag_name: 'v1.4.35'
})
})
)
expect(fetchImpl).toHaveBeenNthCalledWith(
2,
3,
'https://api.github.com/repos/stablyai/orca/releases',
expect.objectContaining({
method: 'POST',
@@ -59,7 +120,7 @@ describe('createDraftRelease', () => {
})
)
const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
expect(createBody).toMatchObject({
tag_name: 'v1.4.36',
name: 'v1.4.36',
@@ -73,6 +134,7 @@ describe('createDraftRelease', () => {
it('marks rc tags as prereleases', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.36' }, { name: 'v1.4.36-rc.1' }]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true }))
@@ -84,7 +146,58 @@ describe('createDraftRelease', () => {
log: vi.fn()
})
const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
expect(createBody.prerelease).toBe(true)
})
it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => {
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.36' }, { name: 'mobile-v0.0.12' }]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
await createDraftRelease({
repo: 'stablyai/orca',
tag: 'v1.4.36',
token: 'token',
fetchImpl,
log: vi.fn()
})
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
expect(generateNotesBody).toEqual({ tag_name: 'v1.4.36', target_commitish: 'v1.4.36' })
expect(generateNotesBody).not.toHaveProperty('previous_tag_name')
})
it('paginates through every tag page before choosing the previous tag', async () => {
const firstPage = Array.from({ length: 100 }, (_, index) => ({ name: `mobile-v0.0.${index}` }))
const fetchImpl = vi
.fn()
.mockResolvedValueOnce(jsonResponse(firstPage))
.mockResolvedValueOnce(jsonResponse([{ name: 'v1.4.35' }]))
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
await createDraftRelease({
repo: 'stablyai/orca',
tag: 'v1.4.36',
token: 'token',
fetchImpl,
log: vi.fn()
})
expect(fetchImpl).toHaveBeenNthCalledWith(
1,
'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=1',
expect.any(Object)
)
expect(fetchImpl).toHaveBeenNthCalledWith(
2,
'https://api.github.com/repos/stablyai/orca/tags?per_page=100&page=2',
expect.any(Object)
)
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
expect(generateNotesBody.previous_tag_name).toBe('v1.4.35')
})
})
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.3m">
<title>downloads: 3.3m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.4m">
<title>downloads: 3.4m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.3m</text>
<text x="93.5" y="14">3.3m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.4m</text>
<text x="93.5" y="14">3.4m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 434 KiB

+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
---
+1
View File
@@ -1,4 +1,5 @@
node_modules/
src/terminal/terminal-webview-engine.generated.ts
.expo/
dist/
/android/
+1
View File
@@ -1,6 +1,7 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["../.oxlintrc.json"],
"ignorePatterns": ["src/terminal/terminal-webview-engine.generated.ts"],
"rules": {
"react-hooks/exhaustive-deps": "off",
"react/no-unescaped-entities": "off",
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.20",
"version": "0.0.22",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 2
"versionCode": 4
},
"plugins": [
"expo-router",
+16 -4
View File
@@ -96,6 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
import {
clearTerminalLiveInputFocusTimer,
focusTerminalLiveInputTarget,
isTerminalLiveInputWithinByteLimit,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
@@ -3134,8 +3135,12 @@ export default function SessionScreen() {
if (!canSend || !liveInputEnabled) {
return
}
liveInputRef.current?.focus()
}, [canSend, liveInputEnabled])
focusTerminalLiveInputTarget(liveInputRef.current, {
keyboardHeight,
refocus: () =>
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
})
}, [canSend, keyboardHeight, liveInputEnabled])
const clearSessionTabActionSheetKeyboardListener = useCallback(() => {
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
@@ -4947,10 +4952,16 @@ export default function SessionScreen() {
{liveInputEnabled ? (
<View style={[styles.inputBar, styles.liveInputBar]}>
<Pressable
style={styles.liveInputFocusTarget}
style={({ pressed }) => [
styles.liveInputFocusTarget,
pressed && styles.liveInputFocusTargetPressed,
!canSend && styles.liveInputFocusTargetDisabled
]}
disabled={!canSend}
onPress={focusLiveInput}
accessibilityLabel="Focus live terminal input"
accessibilityRole="button"
accessibilityLabel="Show keyboard for live terminal input"
accessibilityHint="Typed text is sent directly to the active terminal"
>
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
<MobileTerminalLiveInputStatus
@@ -4981,6 +4992,7 @@ export default function SessionScreen() {
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={handleLiveInputSubmit}
placeholder=""
showSoftInputOnFocus
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
@@ -141,7 +141,20 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
minHeight: 34,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
paddingHorizontal: spacing.sm + 2
},
liveInputFocusTargetPressed: {
backgroundColor: colors.borderSubtle
},
liveInputFocusTargetDisabled: {
opacity: 0.45
},
liveInputCapture: {
+6
View File
@@ -7,6 +7,7 @@
"start": "node scripts/start-expo.mjs",
"android": "expo run:android",
"ios": "expo run:ios",
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
"test": "vitest run",
"lint": "oxlint",
"format": "oxfmt --write .",
@@ -18,6 +19,9 @@
"dependencies": {
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
"@react-native-async-storage/async-storage": "^2.2.0",
"@xterm/addon-unicode11": "0.10.0-beta.285",
"@xterm/addon-webgl": "0.20.0-beta.284",
"@xterm/xterm": "6.1.0-beta.285",
"buffer": "^6.0.3",
"expo": "^55.0.23",
"expo-build-properties": "^55.0.13",
@@ -62,6 +66,8 @@
"@types/react-native": "^0.73.0",
"@types/react-test-renderer": "19.1.0",
"@types/ws": "^8.18.1",
"acorn": "8.15.0",
"esbuild": "0.25.4",
"expo-module-scripts": "^55.0.2",
"oxfmt": "^0.52.0",
"oxlint": "^1.71.0",
+317 -47
View File
@@ -14,6 +14,15 @@ importers:
'@react-native-async-storage/async-storage':
specifier: ^2.2.0
version: 2.2.0(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.285
version: 0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)
'@xterm/addon-webgl':
specifier: 0.20.0-beta.284
version: 0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)
'@xterm/xterm':
specifier: 6.1.0-beta.285
version: 6.1.0-beta.285
buffer:
specifier: ^6.0.3
version: 6.0.3
@@ -141,9 +150,15 @@ importers:
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
acorn:
specifier: 8.15.0
version: 8.15.0
esbuild:
specifier: 0.25.4
version: 0.25.4
expo-module-scripts:
specifier: ^55.0.2
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
oxfmt:
specifier: ^0.52.0
version: 0.52.0
@@ -161,10 +176,10 @@ importers:
version: 5.9.3
vite:
specifier: ^8.0.16
version: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
version: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
packages:
@@ -1157,126 +1172,252 @@ packages:
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@esbuild/aix-ppc64@0.25.4':
resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.4':
resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.28.1':
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.4':
resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.28.1':
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.4':
resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.28.1':
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.4':
resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.28.1':
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.4':
resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.28.1':
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.4':
resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.28.1':
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.4':
resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.1':
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.4':
resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.28.1':
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.4':
resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.28.1':
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.4':
resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.28.1':
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.4':
resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.28.1':
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.4':
resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.28.1':
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.4':
resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.28.1':
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.4':
resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.28.1':
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.4':
resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.28.1':
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.4':
resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.28.1':
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.4':
resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-arm64@0.28.1':
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.4':
resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.1':
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.4':
resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-arm64@0.28.1':
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.4':
resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.1':
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
@@ -1289,24 +1430,48 @@ packages:
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.4':
resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.28.1':
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.4':
resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.28.1':
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.4':
resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.28.1':
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.4':
resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.28.1':
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
@@ -1730,56 +1895,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -1852,56 +2009,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.71.0':
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.71.0':
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.71.0':
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.71.0':
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.71.0':
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.71.0':
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
@@ -2363,42 +2512,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.3':
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.3':
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.3':
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.3':
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.3':
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
@@ -2653,6 +2796,19 @@ packages:
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
'@xterm/addon-unicode11@0.10.0-beta.285':
resolution: {integrity: sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/addon-webgl@0.20.0-beta.284':
resolution: {integrity: sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/xterm@6.1.0-beta.285':
resolution: {integrity: sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==}
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
@@ -2681,8 +2837,8 @@ packages:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -3378,6 +3534,11 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
esbuild@0.25.4:
resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==}
engines: {node: '>=18'}
hasBin: true
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
@@ -4660,28 +4821,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -7675,81 +7832,156 @@ snapshots:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.25.4':
optional: true
'@esbuild/aix-ppc64@0.28.1':
optional: true
'@esbuild/android-arm64@0.25.4':
optional: true
'@esbuild/android-arm64@0.28.1':
optional: true
'@esbuild/android-arm@0.25.4':
optional: true
'@esbuild/android-arm@0.28.1':
optional: true
'@esbuild/android-x64@0.25.4':
optional: true
'@esbuild/android-x64@0.28.1':
optional: true
'@esbuild/darwin-arm64@0.25.4':
optional: true
'@esbuild/darwin-arm64@0.28.1':
optional: true
'@esbuild/darwin-x64@0.25.4':
optional: true
'@esbuild/darwin-x64@0.28.1':
optional: true
'@esbuild/freebsd-arm64@0.25.4':
optional: true
'@esbuild/freebsd-arm64@0.28.1':
optional: true
'@esbuild/freebsd-x64@0.25.4':
optional: true
'@esbuild/freebsd-x64@0.28.1':
optional: true
'@esbuild/linux-arm64@0.25.4':
optional: true
'@esbuild/linux-arm64@0.28.1':
optional: true
'@esbuild/linux-arm@0.25.4':
optional: true
'@esbuild/linux-arm@0.28.1':
optional: true
'@esbuild/linux-ia32@0.25.4':
optional: true
'@esbuild/linux-ia32@0.28.1':
optional: true
'@esbuild/linux-loong64@0.25.4':
optional: true
'@esbuild/linux-loong64@0.28.1':
optional: true
'@esbuild/linux-mips64el@0.25.4':
optional: true
'@esbuild/linux-mips64el@0.28.1':
optional: true
'@esbuild/linux-ppc64@0.25.4':
optional: true
'@esbuild/linux-ppc64@0.28.1':
optional: true
'@esbuild/linux-riscv64@0.25.4':
optional: true
'@esbuild/linux-riscv64@0.28.1':
optional: true
'@esbuild/linux-s390x@0.25.4':
optional: true
'@esbuild/linux-s390x@0.28.1':
optional: true
'@esbuild/linux-x64@0.25.4':
optional: true
'@esbuild/linux-x64@0.28.1':
optional: true
'@esbuild/netbsd-arm64@0.25.4':
optional: true
'@esbuild/netbsd-arm64@0.28.1':
optional: true
'@esbuild/netbsd-x64@0.25.4':
optional: true
'@esbuild/netbsd-x64@0.28.1':
optional: true
'@esbuild/openbsd-arm64@0.25.4':
optional: true
'@esbuild/openbsd-arm64@0.28.1':
optional: true
'@esbuild/openbsd-x64@0.25.4':
optional: true
'@esbuild/openbsd-x64@0.28.1':
optional: true
'@esbuild/openharmony-arm64@0.28.1':
optional: true
'@esbuild/sunos-x64@0.25.4':
optional: true
'@esbuild/sunos-x64@0.28.1':
optional: true
'@esbuild/win32-arm64@0.25.4':
optional: true
'@esbuild/win32-arm64@0.28.1':
optional: true
'@esbuild/win32-ia32@0.25.4':
optional: true
'@esbuild/win32-ia32@0.28.1':
optional: true
'@esbuild/win32-x64@0.25.4':
optional: true
'@esbuild/win32-x64@0.28.1':
optional: true
@@ -9327,13 +9559,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))':
'@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
'@vitest/pretty-format@4.1.9':
dependencies:
@@ -9363,6 +9595,16 @@ snapshots:
'@xmldom/xmldom@0.9.10': {}
'@xterm/addon-unicode11@0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
dependencies:
'@xterm/xterm': 6.1.0-beta.285
'@xterm/addon-webgl@0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)':
dependencies:
'@xterm/xterm': 6.1.0-beta.285
'@xterm/xterm@6.1.0-beta.285': {}
abab@2.0.6: {}
abort-controller@3.0.0:
@@ -9381,7 +9623,7 @@ snapshots:
acorn-globals@7.0.1:
dependencies:
acorn: 8.16.0
acorn: 8.15.0
acorn-walk: 8.3.5
acorn-jsx@5.3.2(acorn@8.17.0):
@@ -9390,9 +9632,9 @@ snapshots:
acorn-walk@8.3.5:
dependencies:
acorn: 8.16.0
acorn: 8.15.0
acorn@8.16.0: {}
acorn@8.15.0: {}
acorn@8.17.0: {}
@@ -10235,6 +10477,34 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
esbuild@0.25.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.4
'@esbuild/android-arm': 0.25.4
'@esbuild/android-arm64': 0.25.4
'@esbuild/android-x64': 0.25.4
'@esbuild/darwin-arm64': 0.25.4
'@esbuild/darwin-x64': 0.25.4
'@esbuild/freebsd-arm64': 0.25.4
'@esbuild/freebsd-x64': 0.25.4
'@esbuild/linux-arm': 0.25.4
'@esbuild/linux-arm64': 0.25.4
'@esbuild/linux-ia32': 0.25.4
'@esbuild/linux-loong64': 0.25.4
'@esbuild/linux-mips64el': 0.25.4
'@esbuild/linux-ppc64': 0.25.4
'@esbuild/linux-riscv64': 0.25.4
'@esbuild/linux-s390x': 0.25.4
'@esbuild/linux-x64': 0.25.4
'@esbuild/netbsd-arm64': 0.25.4
'@esbuild/netbsd-x64': 0.25.4
'@esbuild/openbsd-arm64': 0.25.4
'@esbuild/openbsd-x64': 0.25.4
'@esbuild/sunos-x64': 0.25.4
'@esbuild/win32-arm64': 0.25.4
'@esbuild/win32-ia32': 0.25.4
'@esbuild/win32-x64': 0.25.4
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.1
@@ -10688,7 +10958,7 @@ snapshots:
expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-json-utils: 55.0.2
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/cli': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
@@ -10708,7 +10978,7 @@ snapshots:
jest-snapshot-prettier: prettier@2.8.8
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.9.3))
resolve-workspace-root: 2.0.1
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3)
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- '@babel/core'
@@ -11832,7 +12102,7 @@ snapshots:
jsdom@20.0.3:
dependencies:
abab: 2.0.6
acorn: 8.16.0
acorn: 8.15.0
acorn-globals: 7.0.1
cssom: 0.5.0
cssstyle: 2.3.0
@@ -13463,7 +13733,7 @@ snapshots:
terser@5.46.2:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.16.0
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
@@ -13527,7 +13797,7 @@ snapshots:
picomatch: 4.0.4
typescript: 5.9.3
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3):
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
@@ -13543,7 +13813,7 @@ snapshots:
'@babel/core': 7.29.7
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.29.7)
esbuild: 0.28.1
esbuild: 0.25.4
tsconfig-paths@3.15.0:
dependencies:
@@ -13700,7 +13970,7 @@ snapshots:
- '@types/react'
- '@types/react-dom'
vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0):
vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -13709,16 +13979,16 @@ snapshots:
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 25.9.3
esbuild: 0.28.1
esbuild: 0.25.4
fsevents: 2.3.3
terser: 5.48.0
tsx: 4.22.4
yaml: 2.9.0
vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.9
'@vitest/runner': 4.1.9
'@vitest/snapshot': 4.1.9
@@ -13735,7 +14005,7 @@ snapshots:
tinyexec: 1.1.2
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.9.3
@@ -0,0 +1,108 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { createRequire } from 'node:module'
import * as esbuild from 'esbuild'
const require = createRequire(import.meta.url)
const scriptDir = import.meta.dirname
const mobileRoot = path.resolve(scriptDir, '..')
const outputPath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts')
const target = 'chrome74'
const packages = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl']
async function readPackageVersion(packageName) {
// Why: a package.json module specifier must use '/' — path.join emits '\' on
// Windows, yielding an unresolvable bare specifier that fails postinstall there.
const packageJsonPath = require.resolve(`${packageName}/package.json`)
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'))
return `${packageName}@${packageJson.version}`
}
function htmlText(value, closingTag) {
return value.replace(new RegExp(`</${closingTag}`, 'gi'), `<\\/${closingTag}`)
}
async function buildEngineJs() {
const result = await esbuild.build({
stdin: {
contents: `
import { Terminal } from '@xterm/xterm'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebglAddon } from '@xterm/addon-webgl'
// Why: xterm reaches for these runtime APIs on the terminal-bringup path,
// and esbuild lowers syntax but not runtime APIs. Guarded shims let the
// chrome74-syntax bundle actually run on old WebViews (the #7030 goal)
// instead of throwing at construction and only surfacing the error overlay.
// WeakRef (Chrome 84+): lazily constructed in window-tracking paths.
// Strong retention is fine for a single-document terminal WebView.
if (typeof window.WeakRef === 'undefined') {
window.WeakRef = function WeakRefShim(target) { this.__target = target }
window.WeakRef.prototype.deref = function () { return this.__target }
}
// structuredClone (Chrome 98+): xterm clones its plain-data DEC-mode default
// objects at Terminal construction; a JSON round-trip clones those correctly
// (any undefined-valued keys drop, but every reader treats absent == undefined).
if (typeof window.structuredClone === 'undefined') {
window.structuredClone = function (value) { return JSON.parse(JSON.stringify(value)) }
}
// Element.prototype.replaceChildren (Chrome 86+): used on the row/selection
// render path; polyfill via remove-all + append (Chrome 54+, under the floor).
if (typeof Element !== 'undefined' && !Element.prototype.replaceChildren) {
Element.prototype.replaceChildren = function () {
while (this.firstChild) this.removeChild(this.firstChild)
this.append.apply(this, arguments)
}
}
window.Terminal = Terminal
window.Unicode11Addon = { Unicode11Addon }
window.WebglAddon = { WebglAddon }
`,
resolveDir: mobileRoot,
sourcefile: 'terminal-webview-engine-entry.js'
},
bundle: true,
format: 'iife',
minify: true,
platform: 'browser',
target,
legalComments: 'none',
write: false,
logLevel: 'silent'
})
return result.outputFiles[0].text
}
async function main() {
const [engineJs, rawEngineCss, ...versions] = await Promise.all([
buildEngineJs(),
readFile(require.resolve('@xterm/xterm/css/xterm.css'), 'utf8'),
...packages.map(readPackageVersion)
])
// Why: the no-external-URL regression gate bans http(s):// anywhere in the
// terminal document. These xmlns URIs live inside data: URLs (never fetched);
// percent-encoding the scheme colon satisfies the gate and URI-decodes back
// before the SVG is parsed.
const engineCss = rawEngineCss
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/http:\/\/www\.w3\.org\/2000\/svg/g, 'http%3A//www.w3.org/2000/svg')
const source = [
'// Generated by scripts/build-terminal-webview-engine.mjs.',
`// Packages: ${versions.join(', ')}.`,
`// Target: ${target}. Do not edit by hand; regenerate via pnpm postinstall.`,
`export const XTERM_ENGINE_JS = ${JSON.stringify(htmlText(engineJs, 'script'))}`,
`export const XTERM_ENGINE_CSS = ${JSON.stringify(htmlText(engineCss, 'style'))}`,
''
].join('\n')
await writeFile(outputPath, source)
}
await main()
+15 -45
View File
@@ -22,31 +22,12 @@ function fail(message) {
process.exit(1)
}
function parsePositiveInteger(value, name) {
if (!/^\d+$/.test(value)) {
fail(`${name} must be a positive integer`)
}
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
fail(`${name} must be a positive integer`)
}
return parsed
}
function validateSemver(version, name) {
if (!semverPattern.test(version)) {
fail(`${name} must use x.y.z format`)
}
}
function bumpPatchVersion(version) {
validateSemver(version, 'Current mobile version')
const [major, minor, patch] = version.split('.')
return `${major}.${minor}.${Number(patch) + 1}`
}
function writeOutput(name, value) {
const outputPath = process.env.GITHUB_OUTPUT
if (!outputPath) {
@@ -74,10 +55,6 @@ const tagVersion = githubRef.startsWith(androidTagRefPrefix)
const requestedVersion = input('MOBILE_ANDROID_RELEASE_VERSION')
const bumpPatch = truthy(input('MOBILE_ANDROID_BUMP_PATCH_VERSION'))
if (requestedVersion && bumpPatch) {
fail('Use either MOBILE_ANDROID_RELEASE_VERSION or MOBILE_ANDROID_BUMP_PATCH_VERSION, not both')
}
if (tagVersion) {
validateSemver(tagVersion, 'Android release tag version')
}
@@ -86,43 +63,36 @@ if (requestedVersion) {
validateSemver(requestedVersion, 'MOBILE_ANDROID_RELEASE_VERSION')
}
if (tagVersion && requestedVersion && requestedVersion !== tagVersion) {
fail('MOBILE_ANDROID_RELEASE_VERSION must match the mobile-android-v tag')
if (bumpPatch) {
fail('MOBILE_ANDROID_BUMP_PATCH_VERSION is no longer supported; commit mobile/app.json first')
}
if (tagVersion && bumpPatch) {
fail('MOBILE_ANDROID_BUMP_PATCH_VERSION is only supported for manual branch runs')
if (tagVersion && tagVersion !== currentVersion) {
fail('Android release tag version must match the committed mobile app version')
}
const version =
requestedVersion || tagVersion || (bumpPatch ? bumpPatchVersion(currentVersion) : currentVersion)
if (requestedVersion && requestedVersion !== currentVersion) {
fail('MOBILE_ANDROID_RELEASE_VERSION must match the committed mobile app version')
}
const requestedVersionCode = input('MOBILE_ANDROID_VERSION_CODE')
const bumpVersionCode = truthy(input('MOBILE_ANDROID_BUMP_VERSION_CODE'))
if (requestedVersionCode && bumpVersionCode) {
fail('Use either MOBILE_ANDROID_VERSION_CODE or MOBILE_ANDROID_BUMP_VERSION_CODE, not both')
if (requestedVersionCode || bumpVersionCode) {
// Why: Android rejects lower versionCode installs; release-only bumps leave
// committed dev builds behind shipped APKs and break local testing.
fail('Android versionCode changes must be committed in mobile/app.json before release')
}
const versionCode = requestedVersionCode
? parsePositiveInteger(requestedVersionCode, 'MOBILE_ANDROID_VERSION_CODE')
: bumpVersionCode || version !== currentVersion
? currentVersionCode + 1
: currentVersionCode
expo.version = version
android.versionCode = versionCode
fs.writeFileSync(appConfigPath, `${JSON.stringify(config, null, 2)}\n`)
const tag = `mobile-android-v${version}`
const tag = `mobile-android-v${currentVersion}`
const publishRelease =
githubRef.startsWith(androidTagRefPrefix) || truthy(input('MOBILE_ANDROID_PUBLISH_RELEASE'))
writeOutput('version', version)
writeOutput('android_version_code', String(versionCode))
writeOutput('version', currentVersion)
writeOutput('android_version_code', String(currentVersionCode))
writeOutput('tag', tag)
writeOutput('publish_release', publishRelease ? 'true' : 'false')
console.log(`Prepared Orca Mobile Android ${version} (${versionCode})`)
console.log(`Prepared Orca Mobile Android ${currentVersion} (${currentVersionCode})`)
console.log(`Release tag: ${tag}`)
console.log(`Publish GitHub Release: ${publishRelease ? 'yes' : 'no'}`)
@@ -0,0 +1,92 @@
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const scriptPath = fileURLToPath(
new URL('../../scripts/prepare-android-release.mjs', import.meta.url)
)
const appConfig = {
expo: {
version: '0.0.22',
android: {
versionCode: 4
}
}
}
let tempDirs: string[] = []
function createAppConfig() {
const dir = mkdtempSync(join(tmpdir(), 'orca-android-release-'))
tempDirs.push(dir)
const configPath = join(dir, 'app.json')
const contents = `${JSON.stringify(appConfig, null, 2)}\n`
writeFileSync(configPath, contents)
return { configPath, contents }
}
describe('prepare Android release script', () => {
afterEach(() => {
for (const dir of tempDirs) {
rmSync(dir, { force: true, recursive: true })
}
tempDirs = []
})
it('uses committed Android release identity without mutating app config', () => {
const { configPath, contents } = createAppConfig()
const output = execFileSync(process.execPath, [scriptPath], {
encoding: 'utf8',
env: {
...process.env,
MOBILE_APP_CONFIG_PATH: configPath,
MOBILE_ANDROID_PUBLISH_RELEASE: 'true'
}
})
expect(output).toContain('Prepared Orca Mobile Android 0.0.22 (4)')
expect(output).toContain('Release tag: mobile-android-v0.0.22')
expect(readFileSync(configPath, 'utf8')).toBe(contents)
})
it('rejects release-only Android versionCode bumps', () => {
const { configPath } = createAppConfig()
const result = spawnSync(process.execPath, [scriptPath], {
encoding: 'utf8',
env: {
...process.env,
MOBILE_APP_CONFIG_PATH: configPath,
MOBILE_ANDROID_BUMP_VERSION_CODE: 'true'
}
})
expect(result.status).toBe(1)
expect(result.stderr).toContain(
'Android versionCode changes must be committed in mobile/app.json before release'
)
})
it('rejects release versions that do not match committed app config', () => {
const { configPath } = createAppConfig()
const result = spawnSync(process.execPath, [scriptPath], {
encoding: 'utf8',
env: {
...process.env,
MOBILE_APP_CONFIG_PATH: configPath,
MOBILE_ANDROID_RELEASE_VERSION: '0.0.23'
}
})
expect(result.status).toBe(1)
expect(result.stderr).toContain(
'MOBILE_ANDROID_RELEASE_VERSION must match the committed mobile app version'
)
})
})
@@ -27,9 +27,11 @@ export function MobileTerminalLiveInputStatus({
? 'Tap mic to stop'
: dictation.isProcessing
? 'Transcribing on desktop'
: isAttaching
? 'Uploading image to host'
: 'Keyboard goes directly to terminal'
: dictation.isStarting
? 'Preparing microphone'
: isAttaching
? 'Uploading image to host'
: 'Tap to show keyboard'
return (
<View style={styles.status}>
+74 -96
View File
@@ -1,76 +1,30 @@
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native'
import { View, type StyleProp, type ViewStyle } from 'react-native'
import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { colors } from '../theme/mobile-theme'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
import { XTERM_HTML } from './terminal-webview-html'
import type {
MobileTerminalTheme,
TerminalSelectionEvents,
TerminalWebViewHandle
} from './terminal-webview-contract'
import {
TerminalWebViewEngineErrorOverlay,
useTerminalWebViewEngineErrorState
} from './terminal-webview-engine-error-state'
import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles'
import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
type TerminalOscLinks = TerminalOscLinkRange[]
export type TerminalModes = {
bracketedPasteMode: boolean
altScreen: boolean
mouseTrackingMode: TerminalMouseTrackingMode
sgrMouseMode: boolean
sgrMousePixelsMode: boolean
}
export type TerminalKeyboardAvoidanceMetrics = {
cursorY: number
rows: number
altScreen: boolean
}
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
export type TerminalSelectionEvents = {
onSelectionMode?: (active: boolean) => void
onSelectionCopy?: (text: string) => void
onSelectionEvicted?: () => void
onModesChanged?: (modes: TerminalModes) => void
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
onTerminalTap?: () => void
// Tap landed on a detected file path; RN resolves + opens it.
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
// WebView-detected URL tap; RN chooses the mobile routing destination.
onOpenUrl?: (url: string) => void
// Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it
// here so the app persists it and keeps Settings + other panes in sync.
onTextScaleChange?: (scale: number) => void
}
export type TerminalWebViewHandle = {
write: (data: string) => void
init: (
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinks
) => void
resize: (cols: number, rows: number) => void
// Why: reflow the local xterm buffer (scrollback included) to a new width
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
// latest output. No-op on the alternate screen.
reflow: (cols: number, rows: number) => void
clear: () => void
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
resetZoom: () => void
cancelSelect: () => void
doSelectAll: () => void
// Why: lets callers await the WebView-side `init` rAF chain (term.open
// → renderService population → first paint) so a follow-up measure
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
// the next 'ready' notify after the most recent init.
awaitReady: () => Promise<void>
}
export type {
MobileTerminalTheme,
TerminalKeyboardAvoidanceMetrics,
TerminalModes,
TerminalSelectionEvents,
TerminalWebViewHandle
} from './terminal-webview-contract'
type Props = {
style?: StyleProp<ViewStyle>
@@ -79,18 +33,16 @@ type Props = {
// scale; raw xterm fontSize can't drive apparent size because the fit cancels it.
textScale?: number
onWebReady?: () => void
onEngineError?: (message: string) => void
} & TerminalSelectionEvents
// Why: WebView treats source identity as page identity on some platforms; keep
// parent/session re-renders from reloading xterm and forcing fresh snapshots.
const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
{
style,
terminalTheme,
textScale = 1,
onWebReady,
onEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
@@ -119,6 +71,12 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
// race ahead of term.open() / renderService population.
const readyPromiseRef = useRef<Promise<void> | null>(null)
const readyResolveRef = useRef<(() => void) | null>(null)
const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } =
useTerminalWebViewEngineErrorState(onEngineError)
const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog(
isWebReadyRef,
reportEngineError
)
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
messageIdRef.current += 1
@@ -151,6 +109,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
if (msg.type === 'web-ready') {
isWebReadyRef.current = true
clearWebReadyWatchdog()
clearEngineError()
onWebReady?.()
flushPendingMessages()
} else if (msg.type === 'ready') {
@@ -175,6 +135,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]'
// eslint-disable-next-line no-console
console.log(tag, msg.payload)
} else if (msg.type === 'error') {
const message = typeof msg.message === 'string' ? msg.message : 'Unknown terminal error'
reportEngineError(message, msg.fatal !== false)
} else if (msg.type === 'set-select-mode') {
onSelectionMode?.(!!msg.enabled)
} else if (msg.type === 'selection') {
@@ -246,6 +209,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
},
[
flushPendingMessages,
clearEngineError,
clearWebReadyWatchdog,
reportEngineError,
onWebReady,
onSelectionMode,
onSelectionCopy,
@@ -263,10 +229,16 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const handleLoadStart = useCallback(() => {
isWebReadyRef.current = false
armWebReadyWatchdog()
// Why: messages queued for a previous WebView generation are stale after a reload;
// dropping them avoids replaying terminal chunks before the next init snapshot.
pendingMessages.clear()
}, [pendingMessages])
}, [armWebReadyWatchdog, pendingMessages])
const handleReload = useCallback(() => {
clearEngineError()
webViewRef.current?.reload()
}, [clearEngineError])
useEffect(() => {
postMessage({ type: 'set-theme', terminalTheme })
@@ -289,7 +261,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinks
oscLinks?: TerminalOscLinkRange[]
) {
// Why: arm a fresh ready promise BEFORE posting init. The WebView
// resolves it via the 'ready' notify at the end of its rAF chain.
@@ -395,29 +367,35 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
)
return (
<WebView
ref={webViewRef}
source={XTERM_WEBVIEW_SOURCE}
style={[styles.webview, style]}
originWhitelist={['*']}
javaScriptEnabled
scrollEnabled={false}
// Why: Android parent gesture containers can intercept vertical drags
// before the injected xterm scroll router sees them.
nestedScrollEnabled
scalesPageToFit={false}
// Why: Android WebView defaults textZoom to the system font scale, inflating
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
textZoom={100}
onLoadStart={handleLoadStart}
onMessage={handleMessage}
/>
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, style]}>
<WebView
ref={webViewRef}
source={XTERM_WEBVIEW_SOURCE}
style={TERMINAL_WEBVIEW_FRAME_STYLES.webview}
originWhitelist={['*']}
javaScriptEnabled
scrollEnabled={false}
// Why: Android parent gesture containers can intercept vertical drags
// before the injected xterm scroll router sees them.
nestedScrollEnabled
scalesPageToFit={false}
// Why: Android WebView defaults textZoom to the system font scale, inflating
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
textZoom={100}
onLoadStart={handleLoadStart}
onMessage={handleMessage}
onError={(event) => reportNativeEngineError('Terminal WebView load failed', event)}
onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)}
onRenderProcessGone={(event) =>
reportNativeEngineError('Terminal WebView render process ended', event)
}
onContentProcessDidTerminate={(event) =>
reportNativeEngineError('Terminal WebView content process ended', event)
}
/>
{engineError ? (
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
) : null}
</View>
)
})
const styles = StyleSheet.create({
webview: {
flex: 1,
backgroundColor: colors.terminalBg
}
})
@@ -0,0 +1,178 @@
import { describe, expect, it } from 'vitest'
import {
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
isTerminalLiveHangulCodePoint,
type TerminalLiveMirrorStep
} from './terminal-live-hangul-mirror'
type MirrorRun = {
readonly payloads: readonly string[]
readonly sentText: string
readonly heldText: string
}
function runMirrorSequence(
fieldStates: readonly string[],
options: { readonly commitAtEnd: boolean } = { commitAtEnd: false }
): MirrorRun {
const payloads: string[] = []
let sentText = ''
let heldText = ''
for (const fieldText of fieldStates) {
const step = computeTerminalLiveMirrorStep(sentText, fieldText, { commitHeld: false })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
if (options.commitAtEnd) {
const lastField = sentText + heldText
const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true })
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length > 0) {
payloads.push(payload)
}
sentText = step.nextSentText
heldText = step.heldText
}
return { payloads, sentText, heldText }
}
describe('terminal live hangul mirror', () => {
it('Given single-syllable composition When steps run Then leaks no jamo and commits only the final syllable', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한'])
expect(run.sentText).toBe('한')
expect(run.heldText).toBe('')
})
it('Given multi-syllable composition When a new syllable starts Then streams the stable prefix without erases', () => {
// Given / When
const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['한', '글'])
expect(run.sentText).toBe('한글')
})
it('Given dubeolsik resplit 간→가나 When steps run Then never sends the intermediate syllable', () => {
// Given / When
const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true })
// Then
expect(run.payloads).toEqual(['가', '나'])
expect(run.sentText).toBe('가나')
})
it('Given a timer-committed syllable When composition continues Then erases and recommits via DEL correction', () => {
// Given: '하' was committed by the settle timer
const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true })
expect(buildTerminalLiveMirrorPayload(commit)).toBe('하')
expect(commit.nextSentText).toBe('하')
// When: user keeps composing '하' → '한'
const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', {
commitHeld: false
})
// Then: one DEL erases the stale syllable; the new one is held again
expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f')
expect(correction.nextSentText).toBe('')
expect(correction.heldText).toBe('한')
const recommit = computeTerminalLiveMirrorStep('', '한', { commitHeld: true })
expect(buildTerminalLiveMirrorPayload(recommit)).toBe('한')
})
it('Given pure ASCII typing When steps run Then mirrors immediately with no held text', () => {
// Given / When
const run = runMirrorSequence(['a', 'ab', 'abc'])
// Then
expect(run.payloads).toEqual(['a', 'b', 'c'])
expect(run.heldText).toBe('')
})
it('Given a trailing space after Hangul When the step runs Then the space commits the held syllable', () => {
// Given: '한글' typed, '한' streamed, '글' held
const beforeSpace = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'])
expect(beforeSpace.sentText).toBe('한')
expect(beforeSpace.heldText).toBe('글')
// When
const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한글 ', {
commitHeld: false
})
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('글 ')
expect(step.heldText).toBe('')
expect(step.nextSentText).toBe('한글 ')
})
it('Given a trailing ASCII letter after Hangul When the step runs Then Hangul is committed with the letter', () => {
// Given
const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false })
expect(held.heldText).toBe('한')
// When
const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('한a')
expect(step.heldText).toBe('')
})
it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false })
// Then
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 3,
appendText: '',
nextSentText: '',
heldText: ''
})
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f')
})
it('Given empty field and empty sent text When committing Then produces a zero step', () => {
// Given / When
const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true })
// Then
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
expect(step).toEqual<TerminalLiveMirrorStep>({
eraseCount: 0,
appendText: '',
nextSentText: '',
heldText: ''
})
})
it('Given non-Hangul IME text When the step runs Then it mirrors immediately without holding', () => {
// Given / When
const chinese = computeTerminalLiveMirrorStep('', '你好', { commitHeld: false })
const vietnamese = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false })
// Then
expect(buildTerminalLiveMirrorPayload(chinese)).toBe('你好')
expect(chinese.heldText).toBe('')
expect(buildTerminalLiveMirrorPayload(vietnamese)).toBe('tiếng')
expect(vietnamese.heldText).toBe('')
})
it('Given Hangul code point ranges When checked Then jamo and syllables match and ASCII does not', () => {
expect(isTerminalLiveHangulCodePoint('ㅎ'.codePointAt(0) ?? 0)).toBe(true)
expect(isTerminalLiveHangulCodePoint('한'.codePointAt(0) ?? 0)).toBe(true)
expect(isTerminalLiveHangulCodePoint('a'.codePointAt(0) ?? 0)).toBe(false)
expect(isTerminalLiveHangulCodePoint('あ'.codePointAt(0) ?? 0)).toBe(false)
})
})
@@ -0,0 +1,61 @@
// Why: paused composition should still reach the PTY quickly; corrections make
// a premature commit safe, so this can be short without leaking jamo forever.
export const TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS = 300
const TERMINAL_DEL_BYTE = '\x7f'
export function isTerminalLiveHangulCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
)
}
export type TerminalLiveMirrorStep = {
readonly eraseCount: number
readonly appendText: string
readonly nextSentText: string
readonly heldText: string
}
// Why: React Native exposes no composition events, but Hangul composition only
// mutates the trailing syllable. Holding just that code point keeps the PTY
// echo live while preedit jamo never leak; DEL corrections repair any commit
// that later turns out to be premature.
export function computeTerminalLiveMirrorStep(
sentText: string,
fieldText: string,
options: { readonly commitHeld: boolean }
): TerminalLiveMirrorStep {
const fieldCodePoints = Array.from(fieldText)
const lastCodePoint = fieldCodePoints.at(-1)
const holdLast =
!options.commitHeld &&
lastCodePoint !== undefined &&
isTerminalLiveHangulCodePoint(lastCodePoint.codePointAt(0) ?? 0)
const heldText = holdLast && lastCodePoint !== undefined ? lastCodePoint : ''
const targetCodePoints = holdLast ? fieldCodePoints.slice(0, -1) : fieldCodePoints
const sentCodePoints = Array.from(sentText)
let commonPrefixLength = 0
while (
commonPrefixLength < sentCodePoints.length &&
commonPrefixLength < targetCodePoints.length &&
sentCodePoints[commonPrefixLength] === targetCodePoints[commonPrefixLength]
) {
commonPrefixLength += 1
}
return {
eraseCount: sentCodePoints.length - commonPrefixLength,
appendText: targetCodePoints.slice(commonPrefixLength).join(''),
nextSentText: targetCodePoints.join(''),
heldText
}
}
export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string {
return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText
}
@@ -0,0 +1,49 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
const liveInputStatusSource = readFileSync(
new URL('../session/MobileTerminalLiveInputStatus.tsx', import.meta.url),
'utf8'
)
const commandInputStylesSource = readFileSync(
new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url),
'utf8'
)
function liveInputBarBlock(): string {
const start = sessionRouteSource.indexOf('{liveInputEnabled ? (')
expect(start).toBeGreaterThanOrEqual(0)
const end = sessionRouteSource.indexOf(') : (', start)
expect(end).toBeGreaterThan(start)
return sessionRouteSource.slice(start, end)
}
describe('terminal live input affordance', () => {
it('keeps the live status row wired as the keyboard focus control', () => {
const block = liveInputBarBlock()
expect(block).toContain('onPress={focusLiveInput}')
expect(block).toContain('accessibilityRole="button"')
expect(block).toContain('accessibilityLabel="Show keyboard for live terminal input"')
expect(block).toContain(
'accessibilityHint="Typed text is sent directly to the active terminal"'
)
expect(block).toContain('pressed && styles.liveInputFocusTargetPressed')
expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled')
expect(block).toContain('showSoftInputOnFocus')
expect(sessionRouteSource).toContain('focusTerminalLiveInputTarget(liveInputRef.current')
expect(sessionRouteSource).toContain('keyboardHeight')
expect(sessionRouteSource).toContain('scheduleTerminalLiveInputFocus(liveInputFocusTimerRef')
})
it('makes the live keyboard target visible instead of status-only chrome', () => {
expect(liveInputStatusSource).toContain("'Tap to show keyboard'")
expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised')
expect(commandInputStylesSource).toContain('borderWidth: 1')
expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed')
})
})
@@ -5,10 +5,12 @@ import {
clearTerminalLiveInputFocusTimer,
defaultTerminalLiveInputHandles,
filterTerminalLiveInputDefaultCandidates,
focusTerminalLiveInputTarget,
getTerminalLiveSpecialKeyBytes,
isTerminalLiveInputWithinByteLimit,
pruneTerminalLiveInputHandles,
scheduleTerminalLiveInputFocus,
type TerminalLiveInputFocusTarget,
type TerminalLiveInputFocusTimerRef
} from './terminal-live-input'
@@ -16,6 +18,14 @@ function createTimerRef(): TerminalLiveInputFocusTimerRef {
return { current: null }
}
function createFocusTarget(isFocused: () => boolean): TerminalLiveInputFocusTarget {
return {
blur: vi.fn(),
focus: vi.fn(),
isFocused
}
}
describe('terminal live input', () => {
afterEach(() => {
vi.useRealTimers()
@@ -181,4 +191,37 @@ describe('terminal live input', () => {
expect(focus).not.toHaveBeenCalled()
expect(timerRef.current).toBeNull()
})
it('refocuses an already-focused capture input when the keyboard is closed', () => {
const input = createFocusTarget(() => true)
const refocus = vi.fn()
focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus })
expect(input.blur).toHaveBeenCalledTimes(1)
expect(input.focus).not.toHaveBeenCalled()
expect(refocus).toHaveBeenCalledTimes(1)
})
it('focuses the capture input directly when the keyboard is open', () => {
const input = createFocusTarget(() => true)
const refocus = vi.fn()
focusTerminalLiveInputTarget(input, { keyboardHeight: 240, refocus })
expect(input.blur).not.toHaveBeenCalled()
expect(input.focus).toHaveBeenCalledTimes(1)
expect(refocus).not.toHaveBeenCalled()
})
it('focuses the capture input directly when it is not already focused', () => {
const input = createFocusTarget(() => false)
const refocus = vi.fn()
focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus })
expect(input.blur).not.toHaveBeenCalled()
expect(input.focus).toHaveBeenCalledTimes(1)
expect(refocus).not.toHaveBeenCalled()
})
})
@@ -66,6 +66,17 @@ export type TerminalLiveInputFocusTimerRef = {
current: ReturnType<typeof setTimeout> | null
}
export type TerminalLiveInputFocusTarget = {
readonly focus: () => void
readonly blur: () => void
readonly isFocused?: () => boolean
}
type FocusTerminalLiveInputTargetOptions = {
readonly keyboardHeight: number
readonly refocus: () => void
}
export type TerminalLiveInputDefaultResult = {
enabledHandles: ReadonlySet<string>
defaultedHandles: ReadonlySet<string>
@@ -217,4 +228,23 @@ export function scheduleTerminalLiveInputFocus(
}, delayMs)
}
export function focusTerminalLiveInputTarget(
input: TerminalLiveInputFocusTarget | null,
{ keyboardHeight, refocus }: FocusTerminalLiveInputTargetOptions
): void {
if (!input) {
return
}
if (keyboardHeight <= 0 && input.isFocused?.()) {
// Why: Android can keep a hidden TextInput focused after the IME is dismissed;
// focus() is then a no-op, so force a new focus session to reopen the keyboard.
input.blur()
refocus()
return
}
input.focus()
}
export { TERMINAL_LIVE_INPUT_MAX_BYTES }
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import {
queueTerminalLivePendingFlush,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush,
type TerminalLivePendingFlushState
} from './terminal-live-pending-flush-state'
@@ -64,83 +64,54 @@ describe('terminal live pending flush state', () => {
await expect(controlSend).resolves.toBe(false)
expect(events).toEqual([])
})
})
it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => {
describe('terminal live mirror send queue', () => {
it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => {
// Given
const resolvedState: TerminalLivePendingFlushState = { current: null }
const rejectedState: TerminalLivePendingFlushState = { current: null }
const state: TerminalLivePendingFlushState = { current: null }
const order: string[] = []
const first = queueTerminalLiveMirrorSend(state, async () => {
order.push('first')
return false
})
// When
await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true)
await expect(
queueTerminalLivePendingFlush(rejectedState, async () => {
throw new Error('send failed')
})
).resolves.toBe(false)
const second = queueTerminalLiveMirrorSend(state, async () => {
order.push('second')
return true
})
// Then
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(true)
expect(order).toEqual(['first', 'second'])
})
it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
const first = queueTerminalLiveMirrorSend(state, async () => {
throw new Error('boom')
})
// When
const second = queueTerminalLiveMirrorSend(state, async () => true)
// Then
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(true)
})
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
// When
await queueTerminalLiveMirrorSend(state, async () => true)
await Promise.resolve()
// Then
expect(resolvedState.current).toBeNull()
expect(rejectedState.current).toBeNull()
})
it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => {
// Given
const events: string[] = []
let resolveFirstFlush: (value: boolean) => void = () => {}
const firstFlush = new Promise<boolean>((resolve) => {
resolveFirstFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: firstFlush }
// When
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
events.push('second-flush')
return true
})
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
await Promise.resolve()
// Then
expect(events).toEqual([])
resolveFirstFlush(true)
await expect(secondFlush).resolves.toBe(true)
await expect(controlSend).resolves.toBe(true)
expect(events).toEqual(['second-flush', 'control'])
})
it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => {
// Given
const events: string[] = []
let resolveFirstFlush: (value: boolean) => void = () => {}
const firstFlush = new Promise<boolean>((resolve) => {
resolveFirstFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: firstFlush }
// When
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
events.push('second-flush')
return true
})
const controlSend = sendTerminalLiveControlAfterPendingFlush(
() => waitForTerminalLivePendingFlush(state),
async () => {
events.push('control')
return true
}
)
resolveFirstFlush(false)
// Then
await expect(secondFlush).resolves.toBe(false)
await expect(controlSend).resolves.toBe(false)
expect(events).toEqual([])
expect(state.current).toBeNull()
})
})
@@ -8,22 +8,26 @@ export function waitForTerminalLivePendingFlush(
return state.current ?? Promise.resolve(true)
}
export function queueTerminalLivePendingFlush(
// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped
// delta desyncs every later diff, so this chain runs each send even when the
// previous one failed. state.current should never reject; the catch keeps a
// future raw assignment from skipping a delta.
export function queueTerminalLiveMirrorSend(
state: TerminalLivePendingFlushState,
sendPendingText: () => Promise<boolean>
sendMirrorPayload: () => Promise<boolean>
): Promise<boolean> {
const previousFlush = state.current
const flushPromise = (async () => {
if (previousFlush && !(await previousFlush)) {
return false
const previousSend = state.current
const sendPromise = (async () => {
if (previousSend) {
await previousSend.catch(() => false)
}
return sendPendingText()
return sendMirrorPayload()
})().catch(() => false)
state.current = flushPromise
void flushPromise.then(() => {
if (state.current === flushPromise) {
state.current = sendPromise
void sendPromise.then(() => {
if (state.current === sendPromise) {
state.current = null
}
})
return flushPromise
return sendPromise
}
@@ -1,234 +1,118 @@
import { describe, expect, it } from 'vitest'
import {
TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS,
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText,
getTerminalLiveDeferredTextDelayMs,
getTerminalLiveSpecialKeyDecision,
getTerminalLiveSubmitSequence,
getTerminalLiveTextChangeDecision,
isTerminalLiveTextHangulCandidate,
isTerminalLiveTextImeCandidate
getTerminalLiveSpecialKeyDecision
} from './terminal-live-text-commit'
describe('terminal live text commit', () => {
it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => {
// Given
const koreanCompositionSteps = ['ㅎ', '하', '한'] as const
// When
const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision)
const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now')
const submitSequence = getTerminalLiveSubmitSequence('한')
const composedWordDecision = getTerminalLiveTextChangeDecision('한글')
const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글')
// Then
expect(decisions).toEqual([
{ kind: 'defer', text: 'ㅎ', delayMs: null },
{ kind: 'defer', text: '하', delayMs: null },
{ kind: 'defer', text: '한', delayMs: null }
])
expect(sentImmediately).toEqual([])
expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true)
expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull()
expect(submitSequence).toEqual(['한', '\r'])
expect(composedWordDecision).toEqual({
kind: 'defer',
text: '한글',
delayMs: null
describe('terminal live special key decision', () => {
it('Given an unmapped key Then ignores it', () => {
expect(getTerminalLiveSpecialKeyDecision({ key: 'ㅎ', heldText: '', sentText: '' })).toEqual({
kind: 'ignore'
})
expect(composedWordSubmitSequence).toEqual(['한글', '\r'])
})
it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => {
// Given
const text = 'あ'
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS })
it('Given Backspace with any field text Then edits locally so the mirror diff handles the PTY erase', () => {
// Held syllable present
expect(
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '한', sentText: '' })
).toEqual({ kind: 'local-edit' })
// Only mirrored text present — native edit fires onChangeText and the diff erases
expect(
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '', sentText: 'abc' })
).toEqual({ kind: 'local-edit' })
})
it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => {
// Given
const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const
for (const text of nonHangulImeTexts) {
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
expect(decision).toEqual({
kind: 'defer',
text,
delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
})
}
})
it('Given ASCII text When live text changes Then sends immediately', () => {
// Given
const text = 'abc123'
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
expect(decision).toEqual({ kind: 'send-now', text })
})
it('Given empty text When live text changes Then ignores the change', () => {
// Given
const text = ''
// When
const decision = getTerminalLiveTextChangeDecision(text)
// Then
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
expect(decision).toEqual({ kind: 'ignore' })
})
it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => {
// Given
const pendingText = '한'
// When
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
// Then
expect(backspaceDecision).toEqual({ kind: 'local-edit' })
expect(deleteDecision).toEqual({ kind: 'local-edit' })
})
it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => {
// Given
const pendingText = ''
// When
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
// Then
expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' })
expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' })
})
it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => {
// Given
const pendingText = '한'
// When
const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText })
// Then
expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
})
it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => {
// Given
const pendingText = '한글'
// When
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText })
const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText })
// Then
expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' })
expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' })
})
it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => {
// Given
const pendingText = '한글'
// When
const backspaceDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
pendingText
it('Given Backspace with an empty field Then sends terminal backspace bytes', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'Backspace',
heldText: '',
sentText: ''
})
const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\b',
localEdit: 'backspace',
pendingText
})
const deleteDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b[3~',
localEdit: 'delete',
pendingText
})
const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
pendingText
})
const backspaceText = getTerminalLiveAccessoryLocalEditText({
localEdit: 'backspace',
pendingText
})
const deleteText = getTerminalLiveAccessoryLocalEditText({
localEdit: 'delete',
pendingText
})
// Then
expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' })
expect(customDeleteByteDecision).toEqual({
kind: 'flush-then-send',
pendingText,
bytes: '\x7f'
})
expect(backspaceText).toBe('한')
expect(deleteText).toBe('한글')
expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull()
expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull()
expect(decision.kind).toBe('send-now')
})
it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => {
// Given
const pendingText = ''
// When
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
// Then
expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' })
it('Given a control key with a held syllable Then commits the held text before the bytes', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'Tab',
heldText: '글',
sentText: '한'
})
expect(decision.kind).toBe('commit-held-then-send')
})
it('Given a non-special key When key decision is requested Then ignores it', () => {
// Given
const key = 'a'
it('Given a control key with no held syllable Then sends immediately', () => {
const decision = getTerminalLiveSpecialKeyDecision({
key: 'ArrowUp',
heldText: '',
sentText: 'ls'
})
expect(decision.kind).toBe('send-now')
})
})
describe('terminal live accessory bytes decision', () => {
it('Given a local-edit accessory key with field text Then edits locally', () => {
expect(
getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '한',
sentText: ''
})
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
expect(
getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '',
sentText: 'abc'
})
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
})
// When
const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' })
it('Given raw accessory bytes with a held syllable Then commits held text first', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b',
heldText: '한',
sentText: ''
})
expect(decision).toEqual({ kind: 'commit-held-then-send', bytes: '\x1b' })
})
// Then
expect(decision).toEqual({ kind: 'ignore' })
it('Given raw accessory bytes with nothing held Then sends immediately', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x1b',
heldText: '',
sentText: 'abc'
})
expect(decision).toEqual({ kind: 'send-now', bytes: '\x1b' })
})
it('Given no pending text When submit is requested Then sends only carriage return', () => {
// Given
const pendingText = ''
it('Given a local-edit accessory key with an empty field Then sends the raw bytes', () => {
const decision = getTerminalLiveAccessoryBytesDecision({
bytes: '\x7f',
localEdit: 'backspace',
heldText: '',
sentText: ''
})
expect(decision).toEqual({ kind: 'send-now', bytes: '\x7f' })
})
})
// When
const sequence = getTerminalLiveSubmitSequence(pendingText)
describe('terminal live accessory local edit text', () => {
it('Given backspace Then drops the last code point of the field text', () => {
expect(
getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한글' })
).toBe('한')
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한' })).toBe(
''
)
})
// Then
expect(sequence).toEqual(['\r'])
it('Given forward delete Then keeps the field text unchanged', () => {
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText: '한글' })).toBe(
'한글'
)
})
})
@@ -1,23 +1,15 @@
import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input'
// Why: React Native does not expose portable composition events here, so
// non-Hangul IME text gets a short settle window before being sent to the PTY.
export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150
export type TerminalLiveTextChangeDecision =
| { readonly kind: 'ignore' }
| { readonly kind: 'send-now'; readonly text: string }
| { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null }
export type TerminalLiveSpecialKeyDecision =
| { readonly kind: 'ignore' }
| { readonly kind: 'local-edit' }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
export type TerminalLiveSpecialKeyDecisionInput = {
readonly key: string
readonly pendingText: string
readonly heldText: string
readonly sentText: string
}
export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
@@ -25,78 +17,33 @@ export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
export type TerminalLiveAccessoryBytesDecision =
| { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
| { readonly kind: 'send-now'; readonly bytes: string }
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
export type TerminalLiveAccessoryBytesDecisionInput = {
readonly bytes: string
readonly localEdit?: TerminalLiveAccessoryLocalEdit
readonly pendingText: string
}
export function isTerminalLiveTextImeCandidate(text: string): boolean {
for (const character of text) {
const codePoint = character.codePointAt(0)
if (codePoint !== undefined && codePoint > 0x7f) {
return true
}
}
return false
}
function isHangulCodePoint(codePoint: number): boolean {
return (
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
)
}
export function isTerminalLiveTextHangulCandidate(text: string): boolean {
for (const character of text) {
const codePoint = character.codePointAt(0)
if (codePoint !== undefined && isHangulCodePoint(codePoint)) {
return true
}
}
return false
}
export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision {
if (text.length === 0) {
return { kind: 'ignore' }
}
if (isTerminalLiveTextHangulCandidate(text)) {
return { kind: 'defer', text, delayMs: null }
}
if (isTerminalLiveTextImeCandidate(text)) {
return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }
}
return { kind: 'send-now', text }
}
export function getTerminalLiveDeferredTextDelayMs(text: string): number | null {
return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
readonly heldText: string
readonly sentText: string
}
export function getTerminalLiveSpecialKeyDecision({
key,
pendingText
heldText,
sentText
}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
const bytes = getTerminalLiveSpecialKeyBytes(key)
if (bytes === null) {
return { kind: 'ignore' }
}
if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) {
// Why: native field edits fire onChangeText and the mirror diff emits the
// matching PTY erase; sending raw DEL here as well would double-erase.
if ((key === 'Backspace' || key === 'Delete') && (heldText.length > 0 || sentText.length > 0)) {
return { kind: 'local-edit' }
}
if (pendingText.length > 0) {
return { kind: 'flush-then-send', pendingText, bytes }
if (heldText.length > 0) {
return { kind: 'commit-held-then-send', bytes }
}
return { kind: 'send-now', bytes }
@@ -105,14 +52,15 @@ export function getTerminalLiveSpecialKeyDecision({
export function getTerminalLiveAccessoryBytesDecision({
bytes,
localEdit,
pendingText
heldText,
sentText
}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision {
if (pendingText.length > 0 && localEdit) {
if (localEdit && (heldText.length > 0 || sentText.length > 0)) {
return { kind: 'local-edit', localEdit }
}
if (pendingText.length > 0) {
return { kind: 'flush-then-send', pendingText, bytes }
if (heldText.length > 0) {
return { kind: 'commit-held-then-send', bytes }
}
return { kind: 'send-now', bytes }
@@ -120,26 +68,16 @@ export function getTerminalLiveAccessoryBytesDecision({
export function getTerminalLiveAccessoryLocalEditText({
localEdit,
pendingText
fieldText
}: {
readonly localEdit: TerminalLiveAccessoryLocalEdit
readonly pendingText: string
readonly fieldText: string
}): string {
if (localEdit === 'delete') {
// Why: accessory Delete mirrors forward-delete at the hidden input's end;
// it stays local but does not remove the pending IME text.
return pendingText
// it stays local but does not remove the field text.
return fieldText
}
return Array.from(pendingText).slice(0, -1).join('')
}
export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r']
export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence {
if (pendingText.length === 0) {
return ['\r']
}
return [pendingText, '\r']
return Array.from(fieldText).slice(0, -1).join('')
}
@@ -0,0 +1,64 @@
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
export type TerminalModes = {
bracketedPasteMode: boolean
altScreen: boolean
mouseTrackingMode: TerminalMouseTrackingMode
sgrMouseMode: boolean
sgrMousePixelsMode: boolean
}
export type TerminalKeyboardAvoidanceMetrics = {
cursorY: number
rows: number
altScreen: boolean
}
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
export type TerminalSelectionEvents = {
onSelectionMode?: (active: boolean) => void
onSelectionCopy?: (text: string) => void
onSelectionEvicted?: () => void
onModesChanged?: (modes: TerminalModes) => void
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
onTerminalTap?: () => void
// Tap landed on a detected file path; RN resolves + opens it.
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
// WebView-detected URL tap; RN chooses the mobile routing destination.
onOpenUrl?: (url: string) => void
// Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it
// here so the app persists it and keeps Settings + other panes in sync.
onTextScaleChange?: (scale: number) => void
}
export type TerminalWebViewHandle = {
write: (data: string) => void
init: (
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinkRange[]
) => void
resize: (cols: number, rows: number) => void
// Why: reflow the local xterm buffer (scrollback included) to a new width
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
// latest output. No-op on the alternate screen.
reflow: (cols: number, rows: number) => void
clear: () => void
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
resetZoom: () => void
cancelSelect: () => void
doSelectAll: () => void
// Why: lets callers await the WebView-side `init` rAF chain (term.open
// → renderService population → first paint) so a follow-up measure
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
// the next 'ready' notify after the most recent init.
awaitReady: () => Promise<void>
}
@@ -0,0 +1,130 @@
import { useCallback, useState } from 'react'
import { RefreshCw } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { colors } from '../theme/mobile-theme'
export type NativeWebViewEngineEvent = {
readonly nativeEvent?: object
}
type TerminalWebViewEngineErrorOverlayProps = {
readonly message: string
readonly onReload: () => void
}
type NativeWebViewEngineFields = {
readonly description?: unknown
readonly code?: unknown
readonly statusCode?: unknown
readonly domain?: unknown
readonly didCrash?: unknown
}
export function useTerminalWebViewEngineErrorState(onEngineError?: (message: string) => void) {
const [engineError, setEngineError] = useState<string | null>(null)
const clearEngineError = useCallback(() => setEngineError(null), [])
const reportEngineError = useCallback(
(message: string, fatal: boolean) => {
onEngineError?.(message)
// eslint-disable-next-line no-console
console.warn('[terminal-webview] engine error', message)
if (fatal) {
// Why: the first fatal report is the root cause; later cascades (e.g. the
// web-ready watchdog firing after a process-crash report) must not
// overwrite its more specific diagnostics. clearEngineError resets.
setEngineError((previous) => previous ?? message)
}
},
[onEngineError]
)
const reportNativeEngineError = useCallback(
(context: string, event?: NativeWebViewEngineEvent) => {
reportEngineError(describeNativeWebViewEngineError(context, event), true)
},
[reportEngineError]
)
return { clearEngineError, engineError, reportEngineError, reportNativeEngineError }
}
export function describeNativeWebViewEngineError(
context: string,
event?: NativeWebViewEngineEvent
): string {
const native = event?.nativeEvent as NativeWebViewEngineFields | undefined
const parts = [context]
const description = native?.description
const statusCode = native?.statusCode
const code = native?.code
const domain = native?.domain
if (typeof description === 'string') {
parts.push(description)
}
if (typeof statusCode === 'number') {
parts.push(`status ${statusCode}`)
}
if (typeof code === 'number') {
parts.push(`code ${code}`)
}
if (typeof domain === 'string') {
parts.push(domain)
}
if (native?.didCrash === true) {
parts.push('renderer crashed')
}
return parts.join(' - ')
}
export function TerminalWebViewEngineErrorOverlay({
message,
onReload
}: TerminalWebViewEngineErrorOverlayProps) {
return (
<View style={styles.errorOverlay}>
<Text style={styles.errorTitle}>Terminal failed to load</Text>
<Text style={styles.errorDetail} numberOfLines={4}>
{message}
</Text>
<Pressable accessibilityRole="button" style={styles.reloadButton} onPress={onReload}>
<RefreshCw size={16} color={colors.terminalBg} />
<Text style={styles.reloadButtonText}>Reload</Text>
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
errorOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
padding: 24,
backgroundColor: colors.terminalBg
},
errorTitle: {
color: colors.textPrimary,
fontSize: 16,
fontWeight: '700',
textAlign: 'center'
},
errorDetail: {
color: colors.textSecondary,
fontSize: 13,
lineHeight: 18,
textAlign: 'center'
},
reloadButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
minHeight: 36,
paddingHorizontal: 14,
borderRadius: 6,
backgroundColor: colors.surfaceBright
},
reloadButtonText: {
color: colors.terminalBg,
fontSize: 14,
fontWeight: '700'
}
})
@@ -0,0 +1,178 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TerminalWebView } from './TerminalWebView'
vi.mock('react-native', () => ({
AppState: { currentState: 'active' },
Pressable: 'Pressable',
StyleSheet: {
absoluteFillObject: {
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0
},
create: (styles: unknown) => styles
},
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-webview', () => ({
WebView: 'WebView',
default: 'WebView'
}))
vi.mock('lucide-react-native', () => ({
RefreshCw: 'RefreshCw'
}))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
// Why: mounting TerminalWebView arms the web-ready watchdog; tests must
// unmount so the timer can't survive into later tests and fire in teardown.
let activeRenderer: ReactTestRenderer | null = null
function createTerminalWebViewRenderer(onEngineError = vi.fn()) {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(TerminalWebView, { onEngineError }))
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('TerminalWebView did not render')
}
activeRenderer = renderer
return { onEngineError, renderer }
}
function postWebViewMessage(renderer: ReactTestRenderer, payload: Record<string, unknown>) {
const webView = renderer.root.findByType('WebView')
act(() => {
webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } })
})
}
function renderedText(renderer: ReactTestRenderer): string {
return renderer.root
.findAllByType('Text')
.flatMap((node) => node.props.children)
.join(' ')
}
describe('TerminalWebView engine errors', () => {
afterEach(() => {
if (activeRenderer) {
act(() => {
activeRenderer?.unmount()
})
activeRenderer = null
}
vi.restoreAllMocks()
})
it('renders the reload overlay for fatal engine errors from the WebView', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { onEngineError, renderer } = createTerminalWebViewRenderer()
postWebViewMessage(renderer, {
fatal: true,
message: 'terminal engine missing - SyntaxError - Chrome 74',
type: 'error'
})
expect(onEngineError).toHaveBeenCalledWith('terminal engine missing - SyntaxError - Chrome 74')
expect(renderedText(renderer)).toContain('Terminal failed to load')
expect(renderedText(renderer)).toContain('terminal engine missing - SyntaxError - Chrome 74')
expect(renderedText(renderer)).toContain('Reload')
})
it('reports non-fatal engine errors without covering a live terminal', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { onEngineError, renderer } = createTerminalWebViewRenderer()
postWebViewMessage(renderer, {
fatal: false,
message: 'terminal message failed - malformed chunk',
type: 'error'
})
expect(onEngineError).toHaveBeenCalledWith('terminal message failed - malformed chunk')
expect(renderedText(renderer)).not.toContain('Terminal failed to load')
})
it('keeps the first fatal diagnostics when later fatal reports cascade', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { renderer } = createTerminalWebViewRenderer()
postWebViewMessage(renderer, {
fatal: true,
message: 'terminal engine missing - root cause',
type: 'error'
})
postWebViewMessage(renderer, {
fatal: true,
message: 'Terminal did not initialize - watchdog cascade',
type: 'error'
})
expect(renderedText(renderer)).toContain('terminal engine missing - root cause')
expect(renderedText(renderer)).not.toContain('watchdog cascade')
})
// Why: if the document dies before the glue can post anything (or the RN
// bridge never comes up), no message and no native error handler fires —
// the watchdog must convert that silence into the visible fatal overlay.
it('paints the fatal overlay when web-ready never arrives', () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { onEngineError, renderer } = createTerminalWebViewRenderer()
act(() => {
vi.advanceTimersByTime(15000)
})
expect(onEngineError).toHaveBeenCalledWith(
'Terminal did not initialize - no ready signal from the terminal view'
)
expect(renderedText(renderer)).toContain('Terminal failed to load')
} finally {
vi.useRealTimers()
}
})
it('does not fire the watchdog once web-ready has arrived', () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { onEngineError, renderer } = createTerminalWebViewRenderer()
postWebViewMessage(renderer, { type: 'web-ready' })
act(() => {
vi.advanceTimersByTime(60000)
})
expect(onEngineError).not.toHaveBeenCalled()
expect(renderedText(renderer)).not.toContain('Terminal failed to load')
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,109 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { parse } from 'acorn'
import { describe, expect, it } from 'vitest'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { XTERM_HTML } from './terminal-webview-html'
const terminalHtmlSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
describe('terminal WebView bundled engine', () => {
it('keeps the assembled terminal HTML free of external engine URLs', () => {
expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//)
expect(XTERM_HTML).not.toContain('cdn.jsdelivr.net')
expect(XTERM_HTML).not.toContain('<script src=')
expect(XTERM_HTML).not.toContain('rel="stylesheet" href=')
})
it('parses the bundled engine at the Chrome 74 syntax floor', () => {
expect(() => parse(XTERM_ENGINE_JS, { ecmaVersion: 2019 })).not.toThrow()
})
// Why: the context deliberately omits WeakRef (Chrome 84+) / structuredClone
// (Chrome 98+) and supplies an Element without replaceChildren (Chrome 86+) —
// the engine must evaluate on older WebViews via its own guarded runtime shims,
// which are the linchpin of the old-WebView support (esbuild lowers syntax only).
it('exposes the xterm globals and installs the old-WebView runtime shims', () => {
const window: Record<string, unknown> = {}
class ElementStub {}
const context = {
window,
self: window,
document: {},
Element: ElementStub,
navigator: {
platform: 'Linux armv8l',
userAgent: 'Mozilla/5.0 Chrome/74.0.3729.157'
},
console,
setTimeout,
clearTimeout,
queueMicrotask,
URL
}
new Script(XTERM_ENGINE_JS).runInNewContext(context)
expect(window).toMatchObject({
Terminal: expect.any(Function),
Unicode11Addon: { Unicode11Addon: expect.any(Function) },
WebglAddon: { WebglAddon: expect.any(Function) }
})
const weakRef = window.WeakRef as (new (target: unknown) => { deref(): unknown }) | undefined
expect(typeof weakRef).toBe('function')
const token = {}
expect(new weakRef!(token).deref()).toBe(token)
expect(typeof window.structuredClone).toBe('function')
expect(typeof (ElementStub.prototype as { replaceChildren?: unknown }).replaceChildren).toBe(
'function'
)
})
it('keeps the bundled engine from breaking out of its inline script/style tags', () => {
// Why: the engine JS/CSS are inlined into <script>/<style> blocks. </script
// and </style are neutralized at build time; the tokenizer-escape openers that
// could swallow the rest of the document must also be absent from the bundle.
expect(XTERM_ENGINE_JS).not.toMatch(/<\/script/i)
expect(XTERM_ENGINE_JS).not.toMatch(/<script/i)
expect(XTERM_ENGINE_JS).not.toContain('<!--')
expect(XTERM_ENGINE_CSS).not.toMatch(/<\/style/i)
})
it('reports WebView message handler failures instead of swallowing them', () => {
const start = terminalHtmlSource.indexOf('function handleIncomingMessage')
const end = terminalHtmlSource.indexOf("window.addEventListener('resize'", start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
const handlerSource = terminalHtmlSource.slice(start, end)
expect(handlerSource).toContain('reportEngineError(')
expect(handlerSource).toContain("'terminal init failed'")
expect(handlerSource).toContain("'terminal message failed'")
expect(handlerSource).not.toContain('catch(ex) {}')
})
it('classifies runtime errors by a document-scoped ever-ready latch', () => {
// Why: init() flips `ready` false on every re-init (live width reflow keeps the
// old surface visible meanwhile), so the fatal default and the init-catch must
// key off `everReady` — otherwise a transient reflow error blanks a live
// terminal behind the fatal overlay. The latch stays set for the document.
expect(terminalHtmlSource).toContain('var everReady = false;')
expect(terminalHtmlSource).toContain('everReady = true;')
expect(terminalHtmlSource).toContain('fatal === undefined ? !everReady : !!fatal')
expect(terminalHtmlSource).toContain("msg.type === 'init' && !everReady")
expect(terminalHtmlSource).not.toMatch(/fatal === undefined \? !ready\b/)
})
it('bounds error capture and non-fatal reporting on a degraded engine', () => {
// Why: a constructed-but-broken engine can throw per render frame; both
// onerror capture sites must cap the buffer and non-fatal notifies must
// stop flooding RN while fatal reports always emit.
const capSites = terminalHtmlSource.match(/__engineErrors\.length < 20/g) ?? []
expect(capSites.length).toBe(2)
expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5')
})
})
@@ -0,0 +1,13 @@
import { StyleSheet } from 'react-native'
import { colors } from '../theme/mobile-theme'
export const TERMINAL_WEBVIEW_FRAME_STYLES = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.terminalBg
},
webview: {
flex: 1,
backgroundColor: colors.terminalBg
}
})
+81 -12
View File
@@ -4,6 +4,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ
import { colors } from '../theme/mobile-theme'
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
@@ -47,7 +48,15 @@ export const XTERM_HTML = `<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.285/css/xterm.min.css">
<script>
window.__engineErrors = [];
window.onerror = function(msg) {
// Why: a degraded engine can throw per frame; cap so the capture buffer
// and downstream reporting stay bounded for the document's lifetime.
if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
};
</script>
<style>${XTERM_ENGINE_CSS}</style>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
@@ -192,7 +201,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
<button id="sel-menu-all">Select All</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.285/lib/xterm.min.js"></script><script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.10.0-beta.285/lib/addon-unicode11.js"></script><script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.20.0-beta.284/lib/addon-webgl.js"></script>
<script>${XTERM_ENGINE_JS}</script>
<script>
(function() {
var surface = document.getElementById('terminal-surface');
@@ -214,6 +223,10 @@ export const XTERM_HTML = `<!DOCTYPE html>
var afterDrainCallbacks = [];
var termObserverDisposables = [];
var ready = false;
// Why: init() flips ready false on every re-init (live width reflow included)
// while the old surface stays visible; a document-scoped latch drives the
// fatal/non-fatal decision so a transient reflow cannot blank a live terminal.
var everReady = false;
var currentScale = 1;
// Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a
// gesture only; it resets to 1 on release. The persistent "text size" is the
@@ -745,6 +758,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
requestAnimationFrame(function() {
if (gen !== terminalGeneration) return;
ready = true;
everReady = true;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
if (nextSurface && oldSurface) {
@@ -804,6 +818,47 @@ export const XTERM_HTML = `<!DOCTYPE html>
}
}
function engineErrorText(err) {
if (!err) return '';
if (typeof err === 'string') return err;
if (err && typeof err.message === 'string') return err.message;
try { return String(err); } catch (e) { return ''; }
}
function chromeVersionText() {
var match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\\/([0-9.]+)/);
return match ? 'Chrome ' + match[1] : 'Chrome version unknown';
}
var nonFatalErrorNotifies = 0;
function reportEngineError(context, err, fatal) {
var isFatal = fatal === undefined ? !everReady : !!fatal;
if (!isFatal) {
// Why: a constructed-but-degraded engine can throw per frame; cap
// non-fatal notifies so RN isn't flooded. Fatal reports always emit.
nonFatalErrorNotifies++;
if (nonFatalErrorNotifies > 5) return;
}
var parts = [context];
var errText = engineErrorText(err);
if (errText) parts.push(errText);
if (window.__engineErrors && window.__engineErrors.length) {
parts.push('captured: ' + window.__engineErrors.join(' | '));
}
parts.push(chromeVersionText());
notify({
type: 'error',
fatal: isFatal,
message: parts.join(' - ')
});
}
window.onerror = function(msg, source, line, column, err) {
if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
reportEngineError('terminal runtime error', err || msg);
};
function measureFitDimensions(containerHeightPx, retriesLeft) {
if (typeof retriesLeft !== 'number') retriesLeft = 30;
// Why: init and measure are posted back-to-back from React, but
@@ -1801,17 +1856,27 @@ export const XTERM_HTML = `<!DOCTYPE html>
attachSurfaceEventHandlers(surface);
window.addEventListener('message', function(e) {
function handleIncomingMessage(e) {
var msg;
try {
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
} catch(ex) {}
});
msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
} catch (ex) {
return;
}
try {
handleMsg(msg);
} catch(ex) {
reportEngineError(
msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
ex,
msg && msg.type === 'init' && !everReady
);
}
}
document.addEventListener('message', function(e) {
try {
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
} catch(ex) {}
});
window.addEventListener('message', handleIncomingMessage);
document.addEventListener('message', handleIncomingMessage);
window.addEventListener('resize', function() {
// Why: viewport changed (keyboard open/close, orientation, RN container
@@ -1828,9 +1893,13 @@ export const XTERM_HTML = `<!DOCTYPE html>
if (window.Terminal) {
notify({ type: 'web-ready' });
} else {
notify({ type: 'error', message: 'xterm failed to load' });
reportEngineError('terminal engine missing', 'xterm failed to load', true);
}
})();
</script>
</body>
</html>`
// Why: WebView treats source identity as page identity on some platforms; keep
// parent/session re-renders from reloading xterm and forcing fresh snapshots.
export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import { AppState } from 'react-native'
// Why: if the document dies before the glue can post anything (or the RN
// message bridge never comes up), no webview error and no native handler
// fires — without a native watchdog that failure is a silent blank pane.
const WEB_READY_WATCHDOG_MS = 15000
export function useTerminalWebReadyWatchdog(
isWebReadyRef: RefObject<boolean>,
reportEngineError: (message: string, fatal: boolean) => void
) {
const watchdogRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearWebReadyWatchdog = useCallback(() => {
if (watchdogRef.current) {
clearTimeout(watchdogRef.current)
watchdogRef.current = null
}
}, [])
const armWebReadyWatchdog = useCallback(() => {
clearWebReadyWatchdog()
const fire = () => {
watchdogRef.current = null
if (isWebReadyRef.current) {
return
}
if (AppState.currentState !== 'active') {
// Why: backgrounded WebViews legitimately stall; only judge foreground loads.
watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS)
return
}
reportEngineError(
'Terminal did not initialize - no ready signal from the terminal view',
true
)
}
watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS)
}, [clearWebReadyWatchdog, isWebReadyRef, reportEngineError])
useEffect(() => {
armWebReadyWatchdog()
return clearWebReadyWatchdog
}, [armWebReadyWatchdog, clearWebReadyWatchdog])
return { armWebReadyWatchdog, clearWebReadyWatchdog }
}
@@ -14,7 +14,7 @@ function iifeSource(): string {
function bodyMarkup(): string {
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
const end = XTERM_HTML.indexOf('<script src=')
const end = XTERM_HTML.indexOf('<script>', start)
return XTERM_HTML.slice(start, end)
}
@@ -48,7 +48,7 @@ describe('TerminalWebView text zoom', () => {
const end = terminalWebViewSource.indexOf('/>', start)
expect(end).toBeGreaterThan(start)
const webViewProps = terminalWebViewSource.slice(start, end)
expect(terminalWebViewSource).toContain('const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }')
expect(terminalHtmlSource).toContain('export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }')
expect(webViewProps).toContain('source={XTERM_WEBVIEW_SOURCE}')
expect(webViewProps).not.toContain('source={{ html: XTERM_HTML }}')
})
@@ -113,8 +113,8 @@ describe('TerminalWebView text zoom', () => {
})
it('loads Unicode 11 before replaying mobile terminal bytes', () => {
expect(terminalHtmlSource).toContain('@xterm/xterm@6.1.0-beta.285')
expect(terminalHtmlSource).toContain('@xterm/addon-unicode11@0.10.0-beta.285')
expect(terminalHtmlSource).toContain('XTERM_ENGINE_JS')
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
const open = terminalHtmlSource.indexOf('term.open(surface)')
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)')
@@ -123,8 +123,9 @@ describe('TerminalWebView text zoom', () => {
expect(replay).toBeGreaterThan(unicode)
})
it('uses the newer WebGL-capable xterm stack and desktop font fallbacks', () => {
expect(terminalHtmlSource).toContain('@xterm/addon-webgl@0.20.0-beta.284')
it('uses the bundled WebGL-capable xterm stack and desktop font fallbacks', () => {
expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net')
expect(terminalHtmlSource).toContain('window.WebglAddon.WebglAddon')
expect(terminalHtmlSource).toContain('"SF Mono", "Menlo", "Monaco", "Cascadia Mono"')
expect(terminalHtmlSource).toContain("fontWeight: '300'")
expect(terminalHtmlSource).toContain("fontWeightBold: '500'")
@@ -1,5 +1,14 @@
import { describe, expect, it } from 'vitest'
import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit'
import { createElement, type RefObject } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native'
import { describe, expect, it, vi } from 'vitest'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import {
getTerminalLiveAccessoryInactiveInputCommitResult,
useTerminalLiveAccessoryInputCommit,
type TerminalLiveAccessoryInputCommitResult
} from './use-terminal-live-accessory-input-commit'
type DeferredBoolean = {
readonly promise: Promise<boolean>
@@ -16,7 +25,111 @@ function createDeferredBoolean(): DeferredBoolean {
return { promise, resolve: resolvePromise }
}
describe('terminal live accessory input commit', () => {
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
type AccessoryInputCommitHarnessOptions = {
readonly heldText?: string
readonly sentText?: string
readonly pendingHandle?: string | null
readonly sendResult?: boolean
readonly flushResult?: boolean
readonly waitResult?: boolean
}
type AccessoryInputCommitHarness = {
readonly commit: (
input: TerminalLiveAccessoryInput
) => Promise<TerminalLiveAccessoryInputCommitResult>
readonly sent: readonly string[]
readonly applyLiveInputMirror: ReturnType<typeof vi.fn>
readonly flushPendingLiveInputText: ReturnType<typeof vi.fn>
readonly waitForPendingLiveInputFlush: ReturnType<typeof vi.fn>
readonly unmount: () => void
}
function createAccessoryInputCommitHarness({
heldText = '',
sentText = '',
pendingHandle = null,
sendResult = true,
flushResult = true,
waitResult = true
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
const activeHandle = 'terminal-a'
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const sent: string[] = []
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
current: async (_handle, bytes) => {
sent.push(bytes)
return sendResult
}
}
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
const clearPendingLiveInputCommit = vi.fn(() => {})
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
const setLiveInputCapture = vi.fn((_text: string) => {})
let commit: AccessoryInputCommitHarness['commit'] | null = null
let renderer: ReactTestRenderer | null = null
function Harness(): null {
commit = useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
})
return null
}
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(Harness))
})
} finally {
restoreConsoleError()
}
if (!commit || !renderer) {
throw new Error('terminal live accessory input hook did not render')
}
return {
commit,
sent,
applyLiveInputMirror,
flushPendingLiveInputText,
waitForPendingLiveInputFlush,
unmount: () => {
act(() => renderer?.unmount())
}
}
}
describe('terminal live accessory inactive input commit result', () => {
it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
// Given
const deferredFlush = createDeferredBoolean()
@@ -50,3 +163,68 @@ describe('terminal live accessory input commit', () => {
expect(result).toEqual({ kind: 'suppress-raw' })
})
})
describe('terminal live accessory input commit hook', () => {
it('Given raw accessory bytes with a held syllable When committed Then flushes held text before sending bytes', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한',
sentText: '',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x1b' })
// Then
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
expect(harness.sent).toEqual(['\x1b'])
expect(result).toEqual({ kind: 'handled' })
})
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
// Given
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
// When
const result = await harness.commit({ bytes: '\x1b' })
// Then
expect(result).toEqual({ kind: 'allow-raw' })
expect(harness.flushPendingLiveInputText).not.toHaveBeenCalled()
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한',
sentText: '',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
expect(result).toEqual({ kind: 'handled' })
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '',
sentText: 'ab',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
expect(result).toEqual({ kind: 'handled' })
})
})
@@ -2,8 +2,7 @@ import { useCallback, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import {
getTerminalLiveAccessoryBytesDecision,
getTerminalLiveAccessoryLocalEditText,
getTerminalLiveDeferredTextDelayMs
getTerminalLiveAccessoryLocalEditText
} from './terminal-live-text-commit'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
@@ -14,12 +13,6 @@ export type TerminalLiveAccessoryInputCommitResult =
| { readonly kind: 'handled' }
| { readonly kind: 'suppress-raw' }
type TerminalLiveInputCommitScheduler = (
handle: string,
text: string,
delayMs: number | null
) => void
export async function getTerminalLiveAccessoryInactiveInputCommitResult(
waitForPendingLiveInputFlush: () => Promise<boolean>
): Promise<TerminalLiveAccessoryInputCommitResult> {
@@ -28,13 +21,14 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly pendingLiveInputTextRef: RefObject<string>
readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler
readonly sentLiveInputTextRef: RefObject<string>
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
readonly setLiveInputCapture: (text: string) => void
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
@@ -42,13 +36,14 @@ type TerminalLiveAccessoryInputCommitOptions = {
export function useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
@@ -63,40 +58,33 @@ export function useTerminalLiveAccessoryInputCommit({
if (!liveInputTerminalHandles.has(activeHandle)) {
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
clearPendingLiveInputCommit()
}
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText })
const heldText = ownsPendingState ? heldLiveInputTextRef.current : ''
const sentText = ownsPendingState ? sentLiveInputTextRef.current : ''
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, heldText, sentText })
switch (decision.kind) {
case 'send-now':
// Why: raw accessory bytes must wait behind any in-flight IME text
// flush so composed Hangul reaches the PTY before follow-up controls.
// Why: raw accessory bytes must wait behind any in-flight mirror send
// so composed Hangul reaches the PTY before follow-up controls.
return (await waitForPendingLiveInputFlush())
? { kind: 'allow-raw' }
: { kind: 'suppress-raw' }
case 'local-edit': {
const editedText = getTerminalLiveAccessoryLocalEditText({
localEdit: decision.localEdit,
pendingText
fieldText: sentText + heldText
})
if (editedText.length === 0) {
clearPendingLiveInputCommit()
return { kind: 'handled' }
}
// Why: accessory buttons do not emit native TextInput edits, so the
// pending IME buffer must be edited and rescheduled here.
// field is edited here and the mirror diff syncs the PTY echo.
setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText })
schedulePendingLiveInputCommit(
activeHandle,
editedText,
getTerminalLiveDeferredTextDelayMs(editedText)
)
applyLiveInputMirror(activeHandle, editedText)
return { kind: 'handled' }
}
case 'flush-then-send':
case 'commit-held-then-send':
await sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
@@ -109,13 +97,14 @@ export function useTerminalLiveAccessoryInputCommit({
},
[
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
@@ -3,13 +3,14 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { TextInput } from 'react-native'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit'
import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror'
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
type TerminalLiveInputCommitHarness = {
readonly captures: readonly string[]
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
readonly sent: readonly string[]
readonly setActiveSessionTabType: (next: string | undefined) => void
readonly unmount: () => void
}
@@ -36,6 +37,9 @@ function createTerminalLiveInputCommitHarness({
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
const captures: string[] = []
const setLiveInputCapture = (text: string): void => {
captures.push(text)
}
const liveInputRef: RefObject<TextInput | null> = { current: null }
const liveInputTerminalHandles = new Set([activeHandle])
const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
@@ -48,6 +52,9 @@ function createTerminalLiveInputCommitHarness({
return sendResult
}
}
// The hook keeps live-input state in refs, so a change handler alone never
// re-renders; only a prop change (this variable) re-runs the pending-clear effect.
let currentActiveSessionTabType: string | undefined = 'terminal'
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
let renderer: ReactTestRenderer | null = null
@@ -55,13 +62,13 @@ function createTerminalLiveInputCommitHarness({
handlers = useTerminalLiveInputCommit({
activeHandle,
activeHandleRef,
activeSessionTabType: 'terminal',
activeSessionTabType: currentActiveSessionTabType,
activeSessionTabTypeRef,
liveInputRef,
liveInputTerminalHandles,
liveInputTerminalHandlesRef,
sendLiveTerminalInputRef,
setLiveInputCapture: (text) => captures.push(text)
setLiveInputCapture
})
return null
}
@@ -82,6 +89,15 @@ function createTerminalLiveInputCommitHarness({
captures,
handlers,
sent,
setActiveSessionTabType: (next: string | undefined): void => {
currentActiveSessionTabType = next
// Ref and prop derive from the same activeSessionTab in the real route, so
// they go null together during tab-list lag — keep the harness coupled.
activeSessionTabTypeRef.current = next ?? null
act(() => {
renderer?.update(createElement(Harness))
})
},
unmount: () => {
act(() => renderer?.unmount())
}
@@ -93,18 +109,48 @@ describe('terminal live input commit hook', () => {
vi.useRealTimers()
})
it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => {
it('Given Hangul composition When steps arrive Then streams the stable prefix and never leaks jamo', async () => {
// Given
vi.useFakeTimers()
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When: ㅎ→하→한→한ㄱ→한그→한글 (no settle pause between steps)
for (const fieldText of ['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) {
handlers.handleLiveInputChange(fieldText)
await vi.advanceTimersByTimeAsync(50)
}
// Then: only the stable prefix went out; the trailing syllable is held
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputChange('ㅎ')
await vi.advanceTimersByTimeAsync(1_000)
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
// Then
expect(captures).toEqual(['ㅎ'])
expect(sent).toEqual([])
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a timer-committed syllable When composition continues Then corrects with DEL and recommits', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('하')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
await vi.waitFor(() => expect(sent).toEqual(['하']))
// When
handlers.handleLiveInputChange('한')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['하', '\x7f', '한']))
})
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
@@ -119,6 +165,55 @@ describe('terminal live input commit hook', () => {
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
})
it('Given no pending text When submit is requested Then sends only carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputSubmit()
// Then
await vi.waitFor(() => expect(sent).toEqual(['\r']))
})
it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputSubmit()
await Promise.resolve()
await Promise.resolve()
// Then: the held commit went out but was not accepted, so no \r follows
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given ASCII typing When changes arrive Then mirrors immediately', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('a')
handlers.handleLiveInputChange('ab')
// Then
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
})
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputChange('한 ')
// Then
await vi.waitFor(() => expect(sent).toEqual(['한 ']))
})
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
@@ -145,34 +240,22 @@ describe('terminal live input commit hook', () => {
expect(sent).toEqual(['한'])
})
it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => {
it('Given non-Hangul IME text When changes arrive Then mirrors immediately without a settle window', async () => {
// Given
vi.useFakeTimers()
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
// When
handlers.handleLiveInputChange('你好')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1)
// Then
expect(captures).toEqual(['你好'])
expect(sent).toEqual([])
// When
await vi.advanceTimersByTimeAsync(1)
await vi.waitFor(() => expect(sent).toEqual(['你好']))
handlers.handleLiveInputChange('tiếng Việt')
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt']))
})
it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => {
it('Given a held syllable When the hook unmounts Then cancels the settle timer', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('é')
handlers.handleLiveInputChange('한')
// When
unmount()
@@ -181,4 +264,54 @@ describe('terminal live input commit hook', () => {
// Then
expect(sent).toEqual([])
})
it('Given Backspace with field text When the key arrives Then edits locally without terminal bytes', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } })
// Then
await vi.waitFor(() => expect(sent).toEqual([]))
})
it('Given Tab with a held syllable When the key arrives Then commits the syllable before the tab bytes', async () => {
// Given
const { handlers, sent } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Tab' } })
// Then
await vi.waitFor(() => expect(sent).toEqual(['한', '\t']))
})
it('Given Hangul pending When the tab type lags to undefined Then keeps the composition state', async () => {
// Given: '한' held while the active tab is still a terminal
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When: the mobile tab list momentarily yields no active tab object
setActiveSessionTabType(undefined)
handlers.handleLiveInputSubmit()
// Then: an unknown tab type is not "left the terminal", so pending still flushes
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
})
it('Given Hangul pending When the tab genuinely changes to non-terminal Then clears the composition state', async () => {
// Given: '한' held while the active tab is still a terminal
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
handlers.handleLiveInputChange('한')
// When: the active tab actually becomes a non-terminal (chat) tab
setActiveSessionTabType('chat')
handlers.handleLiveInputSubmit()
// Then: pending was dropped, so submit sends only the carriage return
await vi.waitFor(() => expect(sent).toEqual(['\r']))
})
})
@@ -1,10 +1,6 @@
import { useCallback, useEffect, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import {
getTerminalLiveSpecialKeyDecision,
getTerminalLiveSubmitSequence,
getTerminalLiveTextChangeDecision
} from './terminal-live-text-commit'
import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
@@ -56,11 +52,12 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
setLiveInputCapture
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
const {
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
} = useTerminalLivePendingInputFlush({
activeHandleRef,
@@ -76,10 +73,13 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
if (!pendingHandle) {
return
}
// Why: a lagging mobile tab list briefly yields no active tab object; a
// null/undefined type is "unknown", not "left the terminal" — flush guards
// still block sends if the tab truly changed.
if (
!activeHandle ||
pendingHandle !== activeHandle ||
activeSessionTabType !== 'terminal' ||
(activeSessionTabType != null && activeSessionTabType !== 'terminal') ||
!liveInputTerminalHandles.has(activeHandle)
) {
clearPendingLiveInputCommit()
@@ -93,7 +93,9 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush()
}
if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) {
// Why: external bytes (dictation/paste) land after the field's echo on the
// PTY; the field session must fully end or later diffs would erase them.
if (pendingHandle === handle) {
return flushPendingLiveInputText(handle)
}
return waitForPendingLiveInputFlush()
@@ -108,35 +110,15 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
return
}
const normalizedText = normalizeTerminalTextInput(text)
const decision = getTerminalLiveTextChangeDecision(normalizedText)
switch (decision.kind) {
case 'ignore':
clearPendingLiveInputCommit()
return
case 'send-now':
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.text)
)
return
case 'defer':
// Why: React Native does not expose composition events here, so keep
// probable IME text in the native field until the commit timer settles.
setLiveInputCapture(decision.text)
schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs)
return
default:
decision satisfies never
}
setLiveInputCapture(normalizedText)
applyLiveInputMirror(activeHandle, normalizedText)
},
[
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
liveInputTerminalHandles,
schedulePendingLiveInputCommit,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
setLiveInputCapture
]
)
@@ -145,26 +127,25 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
clearPendingLiveInputCommit()
}
const decision = getTerminalLiveSpecialKeyDecision({
key: event.nativeEvent.key,
pendingText
heldText: ownsPendingState ? heldLiveInputTextRef.current : '',
sentText: ownsPendingState ? sentLiveInputTextRef.current : ''
})
switch (decision.kind) {
case 'ignore':
case 'local-edit':
return
case 'send-now':
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return
case 'flush-then-send':
case 'commit-held-then-send':
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
@@ -186,13 +167,14 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
activeHandle,
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sentLiveInputTextRef,
sendLiveTerminalInputRef,
setLiveInputCapture,
waitForPendingLiveInputFlush
@@ -202,28 +184,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
return
}
const pendingText =
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
const sequence = getTerminalLiveSubmitSequence(pendingText)
if (sequence.length === 2) {
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, sequence[1])
)
return
}
clearPendingLiveInputCommit()
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
sendLiveTerminalInputRef.current(activeHandle, sequence[0])
void sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, '\r')
)
}, [
activeHandle,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
liveInputTerminalHandles,
sendLiveTerminalInputRef,
waitForPendingLiveInputFlush
])
}, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef])
return {
clearPendingLiveInputCommit,
@@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react'
import type { TextInput } from 'react-native'
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
import {
queueTerminalLivePendingFlush,
buildTerminalLiveMirrorPayload,
computeTerminalLiveMirrorStep,
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
} from './terminal-live-hangul-mirror'
import {
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
@@ -16,15 +21,12 @@ type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
}
type TerminalLivePendingInputFlush = {
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly pendingLiveInputTextRef: RefObject<string>
readonly schedulePendingLiveInputCommit: (
handle: string,
text: string,
delayMs: number | null
) => void
readonly sentLiveInputTextRef: RefObject<string>
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
}
@@ -36,104 +38,143 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
sendLiveTerminalInputRef,
setLiveInputCapture
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
const liveInputCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
const pendingLiveInputTextRef = useRef('')
const heldLiveInputTextRef = useRef('')
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
const runMirrorStepRef = useRef<
(handle: string, fieldText: string, commitHeld: boolean) => Promise<boolean>
>(async () => false)
const clearHeldCommitTimer = useCallback(() => {
if (heldCommitTimerRef.current) {
clearTimeout(heldCommitTimerRef.current)
heldCommitTimerRef.current = null
}
}, [])
const resetMirrorState = useCallback(() => {
clearHeldCommitTimer()
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
}, [clearHeldCommitTimer])
const clearPendingLiveInputCommit = useCallback(() => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
}
pendingLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
resetMirrorState()
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
}, [liveInputRef, setLiveInputCapture])
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
}, [])
const flushPendingLiveInputText = useCallback(
async (expectedHandle: string | null): Promise<boolean> => {
const existingFlush = pendingLiveInputFlushRef.current
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
}
const handle = pendingLiveInputHandleRef.current
const text = pendingLiveInputTextRef.current
pendingLiveInputHandleRef.current = null
pendingLiveInputTextRef.current = ''
setLiveInputCapture('')
liveInputRef.current?.setNativeProps({ text: '' })
if (!handle || text.length === 0) {
return existingFlush ?? false
}
const runMirrorStep = useCallback(
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
if (
(expectedHandle !== null && handle !== expectedHandle) ||
handle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal' ||
(activeSessionTabTypeRef.current != null &&
activeSessionTabTypeRef.current !== 'terminal') ||
!liveInputTerminalHandlesRef.current.has(handle)
) {
// Why: a stale handle must not keep local mirror state alive — the next
// active terminal would inherit wrong erase counts. A null tab type is
// "unknown" during tab-list lag, not "left the terminal", so it must not trip.
resetMirrorState()
return false
}
return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () =>
sendLiveTerminalInputRef.current(handle, text)
const step = computeTerminalLiveMirrorStep(sentLiveInputTextRef.current, fieldText, {
commitHeld
})
sentLiveInputTextRef.current = step.nextSentText
heldLiveInputTextRef.current = step.heldText
pendingLiveInputHandleRef.current =
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
clearHeldCommitTimer()
if (step.heldText.length > 0) {
heldCommitTimerRef.current = setTimeout(() => {
heldCommitTimerRef.current = null
const heldField = sentLiveInputTextRef.current + heldLiveInputTextRef.current
void runMirrorStepRef.current(handle, heldField, true)
}, TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
}
const payload = buildTerminalLiveMirrorPayload(step)
if (payload.length === 0) {
return waitForPendingLiveInputFlush()
}
return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () =>
sendLiveTerminalInputRef.current(handle, payload)
)
},
[
activeHandleRef,
activeSessionTabTypeRef,
liveInputRef,
clearHeldCommitTimer,
liveInputTerminalHandlesRef,
resetMirrorState,
sendLiveTerminalInputRef,
setLiveInputCapture
waitForPendingLiveInputFlush
]
)
runMirrorStepRef.current = runMirrorStep
const schedulePendingLiveInputCommit = useCallback(
(handle: string, text: string, delayMs: number | null) => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
}
pendingLiveInputHandleRef.current = handle
pendingLiveInputTextRef.current = text
if (delayMs === null) {
liveInputCommitTimerRef.current = null
return
}
liveInputCommitTimerRef.current = setTimeout(() => {
liveInputCommitTimerRef.current = null
void flushPendingLiveInputText(handle)
}, delayMs)
const applyLiveInputMirror = useCallback(
(handle: string, fieldText: string): void => {
void runMirrorStep(handle, fieldText, false)
},
[flushPendingLiveInputText]
[runMirrorStep]
)
const flushPendingLiveInputText = useCallback(
async (expectedHandle: string | null): Promise<boolean> => {
const handle = pendingLiveInputHandleRef.current
if (!handle) {
return waitForPendingLiveInputFlush()
}
if (expectedHandle !== null && handle !== expectedHandle) {
clearPendingLiveInputCommit()
return waitForPendingLiveInputFlush()
}
const heldText = heldLiveInputTextRef.current
const result =
heldText.length > 0
? await runMirrorStep(handle, sentLiveInputTextRef.current + heldText, true)
: await waitForPendingLiveInputFlush()
// Why: an explicit flush ends the field's editing session; the echoed PTY
// text stays, so local mirror state must restart from empty.
clearPendingLiveInputCommit()
return result
},
[clearPendingLiveInputCommit, runMirrorStep, waitForPendingLiveInputFlush]
)
useEffect(() => {
return () => {
if (liveInputCommitTimerRef.current) {
clearTimeout(liveInputCommitTimerRef.current)
liveInputCommitTimerRef.current = null
if (heldCommitTimerRef.current) {
clearTimeout(heldCommitTimerRef.current)
heldCommitTimerRef.current = null
}
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
pendingLiveInputTextRef.current = ''
pendingLiveInputFlushRef.current = null
}
}, [])
return {
applyLiveInputMirror,
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
pendingLiveInputHandleRef,
pendingLiveInputTextRef,
schedulePendingLiveInputCommit,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.120",
"version": "1.4.121-rc.5",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
@@ -4,7 +4,7 @@ import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree-id'
const {
gitExecFileAsyncMock,
getGitUsernameMock,
resolveLocalGitUsernameMock,
getSshGitUsernameMock,
getSshGitProviderMock,
generateBranchNameMock,
@@ -14,7 +14,7 @@ const {
getConfiguredBranchPrefixMock
} = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getGitUsernameMock: vi.fn(() => 'you'),
resolveLocalGitUsernameMock: vi.fn(async () => 'you'),
getSshGitUsernameMock: vi.fn(async () => 'you'),
getSshGitProviderMock: vi.fn(() => undefined),
generateBranchNameMock: vi.fn(),
@@ -26,8 +26,10 @@ const {
}))
vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock }))
vi.mock('../git/repo', () => ({ getGitUsername: getGitUsernameMock }))
vi.mock('../git/git-username', () => ({ getSshGitUsername: getSshGitUsernameMock }))
vi.mock('../git/git-username', () => ({
getSshGitUsername: getSshGitUsernameMock,
resolveLocalGitUsername: resolveLocalGitUsernameMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock }))
vi.mock('../text-generation/commit-message-text-generation', () => ({
generateBranchNameFromContext: generateBranchNameMock,
@@ -65,7 +67,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => {
beforeEach(() => {
resetFirstWorkBranchRenameState()
vi.clearAllMocks()
getGitUsernameMock.mockReturnValue('you')
resolveLocalGitUsernameMock.mockResolvedValue('you')
getSshGitUsernameMock.mockResolvedValue('you')
getSshGitProviderMock.mockReturnValue(undefined)
computeBranchNameMock.mockImplementation((leaf: string) => `you/${leaf}`)
@@ -15,8 +15,7 @@ import {
import { getCommitMessageModelDiscoveryHostKey } from '../../shared/commit-message-host-key'
import { computeBranchName, getConfiguredBranchPrefix } from '../ipc/worktree-logic'
import { gitExecFileAsync } from '../git/runner'
import { getGitUsername } from '../git/repo'
import { getSshGitUsername } from '../git/git-username'
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import {
branchHasUpstream,
@@ -256,7 +255,7 @@ async function runAutoRename(
const username = provider
? (await getSshGitUsername(provider, repo.path)) || null
: getGitUsername(repo.path) || null
: (await resolveLocalGitUsername(repo.path)) || null
// The model is told not to add a prefix, but sometimes echoes the configured
// one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or
// leak into the display name.
@@ -96,6 +96,11 @@ describe('AntigravityHookService', () => {
expect(script).toContain('payload=$(cat)')
expect(script).toContain("payload='{}'")
expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi')
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive).
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
}
expect(script).toContain('{"decision":""}')
})
+5 -2
View File
@@ -127,7 +127,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
" payload='{}'",
'fi',
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/antigravity" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -138,7 +141,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "hook_event_name=${ORCA_ANTIGRAVITY_EVENT}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
@@ -19,10 +19,6 @@ vi.mock('electron', () => ({
}
}))
vi.mock('../git/repo', () => ({
getGitUsername: vi.fn().mockReturnValue('testuser')
}))
vi.mock('./precheck-runner', () => ({
runAutomationPrecheck: runAutomationPrecheckMock
}))
-4
View File
@@ -19,10 +19,6 @@ vi.mock('electron', () => ({
}
}))
vi.mock('../git/repo', () => ({
getGitUsername: vi.fn().mockReturnValue('testuser')
}))
async function createStore() {
vi.resetModules()
const { Store, initDataPath } = await import('../persistence')
+19
View File
@@ -105,11 +105,30 @@ describe('setupGuestContextMenu', () => {
screenY: 375,
pageUrl: 'https://test.dev/page',
linkUrl: 'https://test.dev/link',
selectionText: '',
canGoBack: true,
canGoForward: true
})
})
it('forwards the native selection text so the renderer can offer Copy', () => {
const guest = makeGuest()
const renderer = makeRenderer()
setupGuestContextMenu({
browserTabId,
guest,
resolveRenderer: () => renderer
})
triggerContextMenu(guest, { x: 10, y: 20, selectionText: 'copied selection' })
expect(rendererSendMock).toHaveBeenCalledWith(
'browser:context-menu-requested',
expect.objectContaining({ selectionText: 'copied selection' })
)
})
it('reads navigation state from navigationHistory', () => {
const deprecatedCanGoBack = vi.fn(() => false)
const deprecatedCanGoForward = vi.fn(() => false)
+5
View File
@@ -95,6 +95,11 @@ export function setupGuestContextMenu(args: {
screenY: cursor.y,
pageUrl,
linkUrl,
// Why: forward the native selection so the renderer can offer a Copy that
// writes it to the clipboard directly, bypassing pages that suppress copy
// via oncopy handlers (the reported bug — the selection is never re-read
// through a page-visible copy event).
selectionText: params.selectionText ?? '',
...navigationState
})
}
@@ -106,6 +106,17 @@ describe('BrowserSessionRegistry', () => {
expect(browserSessionRegistry.resolvePartition('nonexistent')).toBe(ORCA_BROWSER_PARTITION)
})
it('strictly resolves known profile partitions without downgrading unknown profiles', () => {
const profile = browserSessionRegistry.createProfile('isolated', 'Strict Resolve')
expect(profile).not.toBeNull()
expect(browserSessionRegistry.resolveKnownPartition(null)).toBe(ORCA_BROWSER_PARTITION)
expect(browserSessionRegistry.resolveKnownPartition(undefined)).toBe(ORCA_BROWSER_PARTITION)
expect(browserSessionRegistry.resolveKnownPartition('default')).toBe(ORCA_BROWSER_PARTITION)
expect(browserSessionRegistry.resolveKnownPartition(profile!.id)).toBe(profile!.partition)
expect(browserSessionRegistry.resolveKnownPartition('missing-profile')).toBeNull()
})
it('lists all profiles', () => {
const before = browserSessionRegistry.listProfiles().length
browserSessionRegistry.createProfile('isolated', 'List Test')
@@ -326,6 +326,13 @@ class BrowserSessionRegistry {
return this.profiles.get(profileId)?.partition ?? ORCA_BROWSER_PARTITION
}
resolveKnownPartition(profileId: string | null | undefined): string | null {
if (!profileId) {
return ORCA_BROWSER_PARTITION
}
return this.profiles.get(profileId)?.partition ?? null
}
createProfile(scope: BrowserSessionProfileScope, label: string): BrowserSessionProfile | null {
// Why: only the constructor may create the default profile. Allowing the
// renderer to pass scope:'default' would create a second profile sharing
+172
View File
@@ -0,0 +1,172 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf'
const DEFAULT_MARGIN_INCHES = 1 / 2.54
const TTL_MS = 5 * 60 * 1000
describe('buildPrintToPdfOptions', () => {
it('returns empty options for empty params', () => {
expect(buildPrintToPdfOptions({})).toEqual({})
})
it('maps every well-formed field', () => {
expect(
buildPrintToPdfOptions({
landscape: true,
displayHeaderFooter: true,
printBackground: true,
preferCSSPageSize: true,
generateTaggedPDF: true,
generateDocumentOutline: false,
scale: 1.5,
paperWidth: 8.5,
paperHeight: 11,
marginTop: 0.25,
marginBottom: 0.5,
marginLeft: 0.75,
marginRight: 1,
pageRanges: '1-2',
headerTemplate: '<span></span>',
footerTemplate: '<span></span>'
})
).toEqual({
landscape: true,
displayHeaderFooter: true,
printBackground: true,
preferCSSPageSize: true,
generateTaggedPDF: true,
generateDocumentOutline: false,
scale: 1.5,
pageSize: { width: 8.5, height: 11 },
margins: { marginType: 'custom', top: 0.25, bottom: 0.5, left: 0.75, right: 1 },
pageRanges: '1-2',
headerTemplate: '<span></span>',
footerTemplate: '<span></span>'
})
})
it('defaults omitted margin sides to 1cm when any side is set', () => {
expect(buildPrintToPdfOptions({ marginTop: 0.25 }).margins).toEqual({
marginType: 'custom',
top: 0.25,
bottom: DEFAULT_MARGIN_INCHES,
left: DEFAULT_MARGIN_INCHES,
right: DEFAULT_MARGIN_INCHES
})
})
it('drops non-finite and out-of-range numeric options', () => {
const options = buildPrintToPdfOptions({
scale: 0,
paperWidth: Number.POSITIVE_INFINITY,
paperHeight: 11,
marginTop: Number.NaN
})
expect(options.scale).toBeUndefined()
expect(options.pageSize).toBeUndefined()
expect(options.margins).toBeUndefined()
})
it('requires both paper dimensions for pageSize', () => {
expect(buildPrintToPdfOptions({ paperWidth: 8.5 }).pageSize).toBeUndefined()
})
it('ignores wrong-typed options', () => {
expect(buildPrintToPdfOptions({ landscape: 'yes', pageRanges: 7 })).toEqual({})
})
})
describe('CdpPdfStreamStore', () => {
afterEach(() => {
vi.useRealTimers()
})
it('claims only its own handles', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('pdf'))
expect(store.ownsHandle({ handle })).toBe(true)
expect(handle).toMatch(/^orca-pdf-[\da-f-]{36}-\d+$/)
expect(store.ownsHandle({ handle: 'trace-stream' })).toBe(false)
expect(store.ownsHandle({ handle: 'orca-pdf-forged-1' })).toBe(false)
expect(store.ownsHandle({})).toBe(false)
})
it('mints distinct handles per stream', () => {
const store = new CdpPdfStreamStore()
expect(store.create(Buffer.from('a'))).not.toBe(store.create(Buffer.from('b')))
})
it('reads sequential chunks and reports eof', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abcdef'))
expect(store.read({ handle, size: 2 })).toEqual({
data: Buffer.from('ab').toString('base64'),
eof: false
})
expect(store.read({ handle })).toEqual({
data: Buffer.from('cdef').toString('base64'),
eof: true
})
})
it('honors an explicit read offset', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abcdef'))
expect(store.read({ handle, offset: 4 })).toEqual({
data: Buffer.from('ef').toString('base64'),
eof: true
})
})
it('returns an empty eof chunk when reading past the end', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abc'))
expect(store.read({ handle, offset: 99 })).toEqual({ data: '', eof: true })
})
it('returns null for unknown handles', () => {
const store = new CdpPdfStreamStore()
expect(store.read({ handle: 'nope' })).toBeNull()
expect(store.read({})).toBeNull()
})
it('drops a stream on close', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abc'))
store.close({ handle })
expect(store.read({ handle })).toBeNull()
})
it('drops all streams on clear', () => {
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abc'))
store.clear()
expect(store.read({ handle })).toBeNull()
})
it('evicts an abandoned stream after the TTL', () => {
vi.useFakeTimers()
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abc'))
vi.advanceTimersByTime(TTL_MS + 1)
expect(store.read({ handle })).toBeNull()
})
it('refreshes the TTL on each read', () => {
vi.useFakeTimers()
const store = new CdpPdfStreamStore()
const handle = store.create(Buffer.from('abcdef'))
vi.advanceTimersByTime(TTL_MS - 1)
expect(store.read({ handle, size: 1 })).not.toBeNull()
vi.advanceTimersByTime(TTL_MS - 1)
expect(store.read({ handle, size: 1 })).not.toBeNull()
})
})
+177
View File
@@ -0,0 +1,177 @@
import { randomUUID } from 'node:crypto'
import type { PrintToPDFOptions } from 'electron'
const PDF_DEFAULT_MARGIN_INCHES = 1 / 2.54
const PDF_STREAM_CHUNK_BYTES = 1024 * 1024
const PDF_STREAM_HANDLE_PREFIX = 'orca-pdf-'
const PDF_STREAM_TTL_MS = 5 * 60 * 1000
function finiteNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
/**
* Translate CDP `Page.printToPDF` params into Electron `printToPDF` options.
* Only well-formed values are forwarded so a malformed param can never smuggle
* NaN/Infinity into Electron; omitted margin sides default to CDP's 1cm.
*/
export function buildPrintToPdfOptions(params: Record<string, unknown>): PrintToPDFOptions {
const options: PrintToPDFOptions = {}
if (typeof params.landscape === 'boolean') {
options.landscape = params.landscape
}
if (typeof params.displayHeaderFooter === 'boolean') {
options.displayHeaderFooter = params.displayHeaderFooter
}
if (typeof params.printBackground === 'boolean') {
options.printBackground = params.printBackground
}
if (typeof params.preferCSSPageSize === 'boolean') {
options.preferCSSPageSize = params.preferCSSPageSize
}
if (typeof params.generateTaggedPDF === 'boolean') {
options.generateTaggedPDF = params.generateTaggedPDF
}
if (typeof params.generateDocumentOutline === 'boolean') {
options.generateDocumentOutline = params.generateDocumentOutline
}
const scale = finiteNumber(params.scale)
if (scale !== null && scale > 0) {
options.scale = scale
}
const paperWidth = finiteNumber(params.paperWidth)
const paperHeight = finiteNumber(params.paperHeight)
if (paperWidth !== null && paperHeight !== null && paperWidth > 0 && paperHeight > 0) {
options.pageSize = { width: paperWidth, height: paperHeight }
}
const marginTop = finiteNumber(params.marginTop)
const marginBottom = finiteNumber(params.marginBottom)
const marginLeft = finiteNumber(params.marginLeft)
const marginRight = finiteNumber(params.marginRight)
if ([marginTop, marginBottom, marginLeft, marginRight].some((margin) => margin !== null)) {
// CDP and Electron printToPDF both use inches; omitted CDP sides default to 1cm.
options.margins = {
marginType: 'custom',
top: marginTop ?? PDF_DEFAULT_MARGIN_INCHES,
bottom: marginBottom ?? PDF_DEFAULT_MARGIN_INCHES,
left: marginLeft ?? PDF_DEFAULT_MARGIN_INCHES,
right: marginRight ?? PDF_DEFAULT_MARGIN_INCHES
}
}
if (typeof params.pageRanges === 'string') {
options.pageRanges = params.pageRanges
}
if (typeof params.headerTemplate === 'string') {
options.headerTemplate = params.headerTemplate
}
if (typeof params.footerTemplate === 'string') {
options.footerTemplate = params.footerTemplate
}
return options
}
type PdfStream = {
data: Buffer
offset: number
cleanupTimer: ReturnType<typeof setTimeout>
}
export type PdfStreamChunk = {
data: string
eof: boolean
}
/**
* Holds the PDF buffers produced for CDP `transferMode: "ReturnAsStream"` and
* serves them back through `IO.read` / `IO.close`, the path Playwright's
* `page.pdf()` uses. Handles carry a per-instance random prefix so a CDP client
* cannot forge one or collide with a real Chromium IO stream, and each buffer is
* evicted after a TTL so an abandoned stream can never leak memory.
*/
export class CdpPdfStreamStore {
private readonly streams = new Map<string, PdfStream>()
private readonly handlePrefix = `${PDF_STREAM_HANDLE_PREFIX}${randomUUID()}-`
private nextId = 0
/** True when `params.handle` names one of this store's streams. */
ownsHandle(params: Record<string, unknown>): boolean {
return typeof params.handle === 'string' && params.handle.startsWith(this.handlePrefix)
}
create(data: Buffer): string {
const handle = `${this.handlePrefix}${++this.nextId}`
this.streams.set(handle, {
data,
offset: 0,
cleanupTimer: this.scheduleCleanup(handle)
})
return handle
}
/** Read the next chunk, or `null` if the handle is unknown/expired. */
read(params: Record<string, unknown>): PdfStreamChunk | null {
const handle = typeof params.handle === 'string' ? params.handle : ''
const stream = this.streams.get(handle)
if (!stream) {
return null
}
this.refreshCleanup(handle, stream)
const offset = finiteNumber(params.offset)
if (offset !== null) {
stream.offset = Math.max(0, Math.floor(offset))
}
const requestedSize = finiteNumber(params.size)
const size =
requestedSize !== null && requestedSize > 0
? Math.floor(requestedSize)
: PDF_STREAM_CHUNK_BYTES
const start = Math.min(stream.offset, stream.data.length)
const end = Math.min(start + size, stream.data.length)
const chunk = stream.data.subarray(start, end)
stream.offset = end
return { data: chunk.toString('base64'), eof: end >= stream.data.length }
}
close(params: Record<string, unknown>): void {
const handle = typeof params.handle === 'string' ? params.handle : ''
this.delete(handle)
}
clear(): void {
for (const stream of this.streams.values()) {
clearTimeout(stream.cleanupTimer)
}
this.streams.clear()
}
private scheduleCleanup(handle: string): ReturnType<typeof setTimeout> {
const cleanupTimer = setTimeout(() => {
this.delete(handle)
}, PDF_STREAM_TTL_MS)
const maybeNodeTimer = cleanupTimer as { unref?: () => void }
maybeNodeTimer.unref?.()
return cleanupTimer
}
private refreshCleanup(handle: string, stream: PdfStream): void {
clearTimeout(stream.cleanupTimer)
stream.cleanupTimer = this.scheduleCleanup(handle)
}
private delete(handle: string): void {
const stream = this.streams.get(handle)
if (!stream) {
return
}
clearTimeout(stream.cleanupTimer)
this.streams.delete(handle)
}
}
@@ -0,0 +1,394 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { CdpWsProxy } from './cdp-ws-proxy'
import {
connect,
createMockWebContents,
getSendCommandCalls,
getSendCommandMethods,
sendAndReceive,
type MockWebContents
} from './cdp-ws-proxy-test-harness'
vi.mock('electron', () => ({
webContents: { fromId: vi.fn() }
}))
// Why: the proxy focuses the guest webContents natively before Input.insertText,
// which blurs any element a prior DOM.focus targeted. These tests pin the replay
// that re-applies that focus so text lands in the intended field.
describe('CdpWsProxy DOM.focus replay', () => {
let mock: MockWebContents
let proxy: CdpWsProxy
let endpoint: string
beforeEach(async () => {
mock = createMockWebContents()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
proxy = new CdpWsProxy(mock.webContents as any)
endpoint = await proxy.start()
})
afterEach(async () => {
await proxy.stop()
})
it('replays DOM.focus before Input.insertText in the root session', async () => {
const client = await connect(endpoint)
const focusResponse = await sendAndReceive(client, {
id: 14,
method: 'DOM.focus',
params: { backendNodeId: 99 }
})
const insertResponse = await sendAndReceive(client, {
id: 15,
method: 'Input.insertText',
params: { text: 'hello' }
})
expect(focusResponse.id).toBe(14)
expect(insertResponse.id).toBe(15)
expect(insertResponse.result).toEqual({})
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 99 }],
['DOM.focus', { backendNodeId: 99 }],
['Input.insertText', { text: 'hello' }]
])
client.close()
})
it('replays DOM.focus before Input.insertText for OOPIF sessions', async () => {
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 16,
method: 'DOM.focus',
params: { backendNodeId: 123 },
sessionId: 'oopif-session-123'
})
const insertResponse = await sendAndReceive(client, {
id: 17,
method: 'Input.insertText',
params: { text: 'frame text' },
sessionId: 'oopif-session-123'
})
expect(insertResponse.id).toBe(17)
expect(insertResponse.result).toEqual({})
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
['DOM.focus', { backendNodeId: 123 }, 'oopif-session-123'],
['Input.insertText', { text: 'frame text' }, 'oopif-session-123']
])
client.close()
})
it('does not replay DOM.focus after adjacent eval traffic', async () => {
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 18,
method: 'DOM.focus',
params: { backendNodeId: 44 }
})
await sendAndReceive(client, {
id: 19,
method: 'Runtime.callFunctionOn',
params: { functionDeclaration: '() => document.activeElement?.id' }
})
const insertResponse = await sendAndReceive(client, {
id: 20,
method: 'Input.insertText',
params: { text: 'after eval' }
})
expect(insertResponse.id).toBe(20)
expect(insertResponse.result).toEqual({})
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 44 }],
['Runtime.callFunctionOn', { functionDeclaration: '() => document.activeElement?.id' }],
['Input.insertText', { text: 'after eval' }]
])
client.close()
})
it('does not replay a failed DOM.focus on the next Input.insertText', async () => {
let domFocusAttempt = 0
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
const [method] = args as [string]
if (method === 'DOM.focus') {
domFocusAttempt += 1
if (domFocusAttempt === 1) {
throw new Error('Node not found')
}
}
return {}
})
const client = await connect(endpoint)
const focusResponse = await sendAndReceive(client, {
id: 21,
method: 'DOM.focus',
params: { backendNodeId: 55 }
})
const insertResponse = await sendAndReceive(client, {
id: 22,
method: 'Input.insertText',
params: { text: 'fallback' }
})
expect(focusResponse).toEqual({
id: 21,
error: { code: -32000, message: 'Node not found' }
})
expect(insertResponse.id).toBe(22)
expect(insertResponse.result).toEqual({})
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 55 }],
['Input.insertText', { text: 'fallback' }]
])
client.close()
})
it('returns the replay error when the stored DOM.focus fails before Input.insertText', async () => {
let domFocusAttempt = 0
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
const [method] = args as [string]
if (method === 'DOM.focus') {
domFocusAttempt += 1
if (domFocusAttempt === 2) {
throw new Error('Focus target went stale')
}
}
return {}
})
const client = await connect(endpoint)
const focusResponse = await sendAndReceive(client, {
id: 23,
method: 'DOM.focus',
params: { backendNodeId: 77 }
})
const insertResponse = await sendAndReceive(client, {
id: 24,
method: 'Input.insertText',
params: { text: 'blocked' }
})
expect(focusResponse.id).toBe(23)
expect(focusResponse.result).toEqual({})
expect(insertResponse).toEqual({
id: 24,
error: { code: -32000, message: 'Focus target went stale' }
})
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandCalls(mock)).toEqual([
['Page.enable', {}],
['Page.addScriptToEvaluateOnNewDocument', expect.any(Object)],
['DOM.focus', { backendNodeId: 77 }],
['DOM.focus', { backendNodeId: 77 }]
])
client.close()
})
it('still replays DOM.focus when Input.insertText is dispatched while DOM.focus is still in flight', async () => {
let resolveFocus: (v: Record<string, unknown>) => void
const focusPromise = new Promise<Record<string, unknown>>((r) => {
resolveFocus = r
})
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
const [method] = args as [string]
if (method === 'DOM.focus') {
return focusPromise
}
return {}
})
const client = await connect(endpoint)
const responses: Record<string, unknown>[] = []
client.on('message', (data) => {
responses.push(JSON.parse(data.toString()))
})
client.send(JSON.stringify({ id: 25, method: 'DOM.focus', params: { backendNodeId: 66 } }))
await new Promise((r) => setTimeout(r, 10))
// Why: dispatch the next message before the in-flight DOM.focus sendCommand
// resolves, reproducing the pipelining race the fix closes.
client.send(
JSON.stringify({ id: 26, method: 'Input.insertText', params: { text: 'pipelined' } })
)
await new Promise((r) => setTimeout(r, 20))
resolveFocus!({})
await new Promise((r) => setTimeout(r, 20))
expect(responses).toHaveLength(2)
const focusResponse = responses.find((r) => r.id === 25)
const insertResponse = responses.find((r) => r.id === 26)
expect(focusResponse?.result).toEqual({})
expect(insertResponse?.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'DOM.focus',
'Input.insertText'
])
client.close()
})
it('clears the pending DOM.focus replay when Page.bringToFront intervenes', async () => {
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 27,
method: 'DOM.focus',
params: { backendNodeId: 88 }
})
await sendAndReceive(client, { id: 28, method: 'Page.bringToFront', params: {} })
const insertResponse = await sendAndReceive(client, {
id: 29,
method: 'Input.insertText',
params: { text: 'no replay' }
})
expect(insertResponse.id).toBe(29)
expect(insertResponse.result).toEqual({})
// Why: both Page.bringToFront and Input.insertText natively call focus(),
// independent of the (now-cleared) DOM.focus replay.
expect(mock.webContents.focus).toHaveBeenCalledTimes(2)
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'Input.insertText'
])
client.close()
})
it('clears the pending DOM.focus replay when Page.captureScreenshot intervenes', async () => {
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 30,
method: 'DOM.focus',
params: { backendNodeId: 91 }
})
await sendAndReceive(client, { id: 31, method: 'Page.captureScreenshot', params: {} })
const insertResponse = await sendAndReceive(client, {
id: 32,
method: 'Input.insertText',
params: { text: 'no replay after screenshot' }
})
expect(insertResponse.id).toBe(32)
expect(insertResponse.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'Page.captureScreenshot',
'Input.insertText'
])
client.close()
})
it('does not replay a pending DOM.focus across a client reconnect', async () => {
const first = await connect(endpoint)
await sendAndReceive(first, { id: 33, method: 'DOM.focus', params: { backendNodeId: 12 } })
first.close()
// Why: a new client connection replaces the previous one; the stale focus
// stored by the departed client must not leak into the new client's insert.
const second = await connect(endpoint)
const insertResponse = await sendAndReceive(second, {
id: 34,
method: 'Input.insertText',
params: { text: 'fresh client' }
})
expect(insertResponse.id).toBe(34)
expect(insertResponse.result).toEqual({})
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'DOM.focus',
'Input.insertText'
])
second.close()
})
it('does not replay or insert once the client disconnects mid-DOM.focus', async () => {
let resolveFocus: (v: Record<string, unknown>) => void = () => {}
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
const [method] = args as [string]
if (method === 'DOM.focus') {
return new Promise<Record<string, unknown>>((resolve) => {
resolveFocus = resolve
})
}
return {}
})
const client = await connect(endpoint)
client.send(JSON.stringify({ id: 35, method: 'DOM.focus', params: { backendNodeId: 7 } }))
await new Promise((r) => setTimeout(r, 10))
// Pipeline the insert while DOM.focus is still in flight, then drop the client.
client.send(JSON.stringify({ id: 36, method: 'Input.insertText', params: { text: 'gone' } }))
await new Promise((r) => setTimeout(r, 10))
client.close()
await new Promise((r) => setTimeout(r, 10))
// Resolve the in-flight DOM.focus only after the client is gone.
resolveFocus({})
await new Promise((r) => setTimeout(r, 20))
// Why: a disconnected client's focus replay and insert must not reach the live page.
const methods = getSendCommandMethods(mock)
expect(methods.filter((m) => m === 'DOM.focus')).toHaveLength(1)
expect(methods).not.toContain('Input.insertText')
})
it('does not insert once the client disconnects during the DOM.focus replay', async () => {
let domFocusCalls = 0
let resolveReplay: (v: Record<string, unknown>) => void = () => {}
mock.webContents.debugger.sendCommand.mockImplementation(async (...args: unknown[]) => {
const [method] = args as [string]
if (method === 'DOM.focus') {
domFocusCalls += 1
// Why: let the first DOM.focus resolve so a replay is queued, then hang the
// replay so the client can disconnect while it is in flight.
if (domFocusCalls === 2) {
return new Promise<Record<string, unknown>>((resolve) => {
resolveReplay = resolve
})
}
}
return {}
})
const client = await connect(endpoint)
await sendAndReceive(client, { id: 37, method: 'DOM.focus', params: { backendNodeId: 9 } })
client.send(JSON.stringify({ id: 38, method: 'Input.insertText', params: { text: 'late' } }))
await new Promise((r) => setTimeout(r, 10))
client.close()
await new Promise((r) => setTimeout(r, 10))
resolveReplay({})
await new Promise((r) => setTimeout(r, 20))
expect(getSendCommandMethods(mock)).not.toContain('Input.insertText')
})
})
@@ -0,0 +1,118 @@
import { vi, type Mock } from 'vitest'
import WebSocket from 'ws'
type DebuggerListener = (...args: unknown[]) => void
type MockDebugger = {
isAttached: Mock<() => boolean>
attach: Mock<() => void>
detach: Mock<() => void>
sendCommand: Mock<
(
method?: string,
params?: Record<string, unknown>,
sessionId?: string
) => Promise<Record<string, unknown>>
>
on: Mock<(event: string, handler: DebuggerListener) => void>
removeListener: Mock<(event: string, handler: DebuggerListener) => void>
}
// Why: annotate the return explicitly so the exported inferred type stays nameable under
// composite declaration emit — otherwise the vi.fn() mocks leak @vitest/spy's Procedure
// and tsgo reports TS2883.
export type MockWebContents = {
webContents: {
debugger: MockDebugger
isDestroyed: () => boolean
focus: Mock<() => void>
printToPDF: Mock<() => Promise<Buffer>>
reload: Mock<() => void>
reloadIgnoringCache: Mock<() => void>
getTitle: Mock<() => string>
getURL: Mock<() => string>
}
listeners: Map<string, DebuggerListener[]>
destroy: () => void
emit: (event: string, ...args: unknown[]) => void
}
export function createMockWebContents(): MockWebContents {
const listeners = new Map<string, DebuggerListener[]>()
let debuggerAttached = false
let destroyed = false
const debuggerObj = {
isAttached: vi.fn(() => debuggerAttached),
attach: vi.fn(() => {
debuggerAttached = true
}),
detach: vi.fn(() => {
debuggerAttached = false
}),
sendCommand: vi.fn(
async (_method?: string, _params?: Record<string, unknown>, _sessionId?: string) => ({})
),
on: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
arr.push(handler)
listeners.set(event, arr)
}),
removeListener: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
listeners.set(
event,
arr.filter((h) => h !== handler)
)
})
}
return {
webContents: {
debugger: debuggerObj,
isDestroyed: () => destroyed,
focus: vi.fn(),
printToPDF: vi.fn(async () => Buffer.from('%PDF-test')),
reload: vi.fn(),
reloadIgnoringCache: vi.fn(),
getTitle: vi.fn(() => 'Example'),
getURL: vi.fn(() => 'https://example.com')
},
listeners,
destroy() {
destroyed = true
},
emit(event: string, ...args: unknown[]) {
for (const handler of listeners.get(event) ?? []) {
handler(...args)
}
}
}
}
export type SendCommandCall = [string, Record<string, unknown>?, string?]
export function connect(endpoint: string): Promise<WebSocket> {
return new Promise((resolve) => {
const ws = new WebSocket(endpoint)
ws.on('open', () => resolve(ws))
})
}
export function sendAndReceive(
ws: WebSocket,
msg: Record<string, unknown>
): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
ws.once('message', (data) => resolve(JSON.parse(data.toString())))
ws.send(JSON.stringify(msg))
})
}
export function getSendCommandCalls(mock: MockWebContents): SendCommandCall[] {
return mock.webContents.debugger.sendCommand.mock.calls as unknown as SendCommandCall[]
}
export function getSendCommandMethods(mock: MockWebContents): string[] {
return getSendCommandCalls(mock).map((call) => call[0])
}
+252 -114
View File
@@ -1,67 +1,21 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import WebSocket from 'ws'
import { CdpWsProxy } from './cdp-ws-proxy'
import {
connect,
createMockWebContents,
getSendCommandCalls,
getSendCommandMethods,
sendAndReceive,
type MockWebContents
} from './cdp-ws-proxy-test-harness'
vi.mock('electron', () => ({
webContents: { fromId: vi.fn() }
}))
type DebuggerListener = (...args: unknown[]) => void
function createMockWebContents() {
const listeners = new Map<string, DebuggerListener[]>()
let debuggerAttached = false
let destroyed = false
const debuggerObj = {
isAttached: vi.fn(() => debuggerAttached),
attach: vi.fn(() => {
debuggerAttached = true
}),
detach: vi.fn(() => {
debuggerAttached = false
}),
sendCommand: vi.fn(
async (_method?: string, _params?: Record<string, unknown>, _sessionId?: string) => ({})
),
on: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
arr.push(handler)
listeners.set(event, arr)
}),
removeListener: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
listeners.set(
event,
arr.filter((h) => h !== handler)
)
})
}
return {
webContents: {
debugger: debuggerObj,
isDestroyed: () => destroyed,
focus: vi.fn(),
reload: vi.fn(),
reloadIgnoringCache: vi.fn(),
getTitle: vi.fn(() => 'Example'),
getURL: vi.fn(() => 'https://example.com')
},
listeners,
destroy() {
destroyed = true
},
emit(event: string, ...args: unknown[]) {
for (const handler of listeners.get(event) ?? []) {
handler(...args)
}
}
}
}
describe('CdpWsProxy', () => {
let mock: ReturnType<typeof createMockWebContents>
let mock: MockWebContents
let proxy: CdpWsProxy
let endpoint: string
@@ -76,38 +30,14 @@ describe('CdpWsProxy', () => {
await proxy.stop()
})
function connect(): Promise<WebSocket> {
return new Promise((resolve) => {
const ws = new WebSocket(endpoint)
ws.on('open', () => resolve(ws))
})
function expectPdfStreamHandle(response: Record<string, unknown>): string {
const result = response.result as Record<string, unknown>
expect(result.data).toBe('')
expect(result.stream).toEqual(expect.stringMatching(/^orca-pdf-[\da-f-]{36}-\d+$/))
return result.stream as string
}
function sendAndReceive(
ws: WebSocket,
msg: Record<string, unknown>
): Promise<Record<string, unknown>> {
return new Promise((resolve) => {
ws.once('message', (data) => resolve(JSON.parse(data.toString())))
ws.send(JSON.stringify(msg))
})
}
type SendCommandCall = [string, Record<string, unknown>?, string?]
function getSendCommandCalls(): SendCommandCall[] {
const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [
string,
Record<string, unknown>?,
string?
][]
return calls
}
function getSendCommandMethods(): string[] {
const calls = getSendCommandCalls()
return calls.map((call) => call[0])
}
const defaultPdfMarginInches = 1 / 2.54
it('starts on a random port and returns ws:// URL', () => {
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
@@ -131,7 +61,7 @@ describe('CdpWsProxy', () => {
it('correlates CDP request/response IDs', async () => {
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({ tree: 'nodes' })
const ws = connect()
const ws = connect(endpoint)
const client = await ws
const response = await sendAndReceive(client, {
id: 42,
@@ -147,7 +77,7 @@ describe('CdpWsProxy', () => {
it('returns error response when sendCommand fails', async () => {
mock.webContents.debugger.sendCommand.mockRejectedValueOnce(new Error('Node not found'))
const client = await connect()
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 7,
method: 'DOM.describeNode',
@@ -160,7 +90,7 @@ describe('CdpWsProxy', () => {
})
it('returns an error instead of crashing when a command arrives after tab destruction', async () => {
const client = await connect()
const client = await connect(endpoint)
mock.destroy()
const response = await sendAndReceive(client, {
@@ -197,7 +127,7 @@ describe('CdpWsProxy', () => {
})
.mockResolvedValueOnce({ result: 'fast' })
const client = await connect()
const client = await connect(endpoint)
const responses: Record<string, unknown>[] = []
client.on('message', (data) => {
@@ -232,11 +162,11 @@ describe('CdpWsProxy', () => {
)
.mockResolvedValueOnce({ result: 'new-client' })
const firstClient = await connect()
const firstClient = await connect(endpoint)
firstClient.send(JSON.stringify({ id: 1, method: 'DOM.enable', params: {} }))
await new Promise((resolve) => setTimeout(resolve, 10))
const secondClient = await connect()
const secondClient = await connect(endpoint)
const responses: Record<string, unknown>[] = []
secondClient.on('message', (data) => {
responses.push(JSON.parse(data.toString()))
@@ -258,7 +188,7 @@ describe('CdpWsProxy', () => {
it('forwards sessionId to sendCommand for OOPIF support', async () => {
mock.webContents.debugger.sendCommand.mockResolvedValueOnce({})
const client = await connect()
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 1,
method: 'DOM.enable',
@@ -277,7 +207,7 @@ describe('CdpWsProxy', () => {
// ── Event forwarding ──
it('forwards CDP events from debugger to client', async () => {
const client = await connect()
const client = await connect(endpoint)
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
client.on('message', (data) => resolve(JSON.parse(data.toString())))
@@ -292,7 +222,7 @@ describe('CdpWsProxy', () => {
})
it('forwards sessionId in events when present', async () => {
const client = await connect()
const client = await connect(endpoint)
const eventPromise = new Promise<Record<string, unknown>>((resolve) => {
client.on('message', (data) => resolve(JSON.parse(data.toString())))
@@ -306,7 +236,7 @@ describe('CdpWsProxy', () => {
})
it('does not focus the guest for Runtime.evaluate polling commands', async () => {
const client = await connect()
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 9,
@@ -319,7 +249,7 @@ describe('CdpWsProxy', () => {
})
it('still focuses the guest for Input.insertText', async () => {
const client = await connect()
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 10,
@@ -328,11 +258,16 @@ describe('CdpWsProxy', () => {
})
expect(mock.webContents.focus).toHaveBeenCalledTimes(1)
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Input.insertText'
])
client.close()
})
it('primes lifecycle events for Page.navigate', async () => {
const client = await connect()
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 11,
@@ -342,7 +277,7 @@ describe('CdpWsProxy', () => {
expect(response.id).toBe(11)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Network.enable',
@@ -354,7 +289,7 @@ describe('CdpWsProxy', () => {
})
it('primes lifecycle events for Page.reload and preserves response id', async () => {
const client = await connect()
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 12,
@@ -363,7 +298,7 @@ describe('CdpWsProxy', () => {
expect(response.id).toBe(12)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Network.enable',
@@ -371,12 +306,12 @@ describe('CdpWsProxy', () => {
'Page.setLifecycleEventsEnabled'
])
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
expect(getSendCommandMethods()).not.toContain('Page.reload')
expect(getSendCommandMethods(mock)).not.toContain('Page.reload')
client.close()
})
it('preserves explicit Page.navigate session during lifecycle priming', async () => {
const client = await connect()
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 14,
@@ -385,7 +320,7 @@ describe('CdpWsProxy', () => {
sessionId: 'iframe-session-123'
})
expect(getSendCommandCalls().slice(2)).toEqual([
expect(getSendCommandCalls(mock).slice(2)).toEqual([
['Network.enable', {}, 'iframe-session-123'],
['Page.enable', {}, 'iframe-session-123'],
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
@@ -395,7 +330,7 @@ describe('CdpWsProxy', () => {
})
it('forwards explicit Page.reload session after lifecycle priming', async () => {
const client = await connect()
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 15,
@@ -404,7 +339,7 @@ describe('CdpWsProxy', () => {
sessionId: 'iframe-session-123'
})
expect(getSendCommandCalls().slice(2)).toEqual([
expect(getSendCommandCalls(mock).slice(2)).toEqual([
['Network.enable', {}, 'iframe-session-123'],
['Page.enable', {}, 'iframe-session-123'],
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
@@ -416,7 +351,7 @@ describe('CdpWsProxy', () => {
})
it('rejects root Page.reload params that direct webContents reload cannot honor', async () => {
const client = await connect()
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 16,
@@ -433,12 +368,12 @@ describe('CdpWsProxy', () => {
})
expect(mock.webContents.reload).not.toHaveBeenCalled()
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
expect(getSendCommandMethods()).not.toContain('Network.enable')
expect(getSendCommandMethods(mock)).not.toContain('Network.enable')
client.close()
})
it('still reloads when lifecycle priming stalls', async () => {
const client = await connect()
const client = await connect(endpoint)
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
if (method === 'Network.enable') {
return new Promise(() => {})
@@ -457,7 +392,7 @@ describe('CdpWsProxy', () => {
})
it('does not reload after the requesting client disconnects during priming', async () => {
const client = await connect()
const client = await connect(endpoint)
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
if (method === 'Network.enable') {
return new Promise(() => {})
@@ -475,7 +410,7 @@ describe('CdpWsProxy', () => {
})
it('forwards Runtime.evaluate without lifecycle priming', async () => {
const client = await connect()
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 13,
@@ -485,7 +420,7 @@ describe('CdpWsProxy', () => {
expect(response.id).toBe(13)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
expect(getSendCommandMethods(mock)).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Runtime.evaluate'
@@ -493,12 +428,215 @@ describe('CdpWsProxy', () => {
client.close()
})
it('prints PDF data through native webContents printToPDF', async () => {
const client = await connect(endpoint)
const response = await sendAndReceive(client, {
id: 19,
method: 'Page.printToPDF',
params: {
landscape: true,
printBackground: true,
paperWidth: 8.5,
paperHeight: 11,
marginTop: 0.25,
marginBottom: 0.5,
marginLeft: 0.75,
marginRight: 1,
pageRanges: '1-2',
preferCSSPageSize: true
}
})
expect(response).toEqual({
id: 19,
result: { data: Buffer.from('%PDF-test').toString('base64') }
})
expect(mock.webContents.printToPDF).toHaveBeenCalledWith({
landscape: true,
printBackground: true,
pageSize: { width: 8.5, height: 11 },
margins: {
marginType: 'custom',
top: 0.25,
bottom: 0.5,
left: 0.75,
right: 1
},
pageRanges: '1-2',
preferCSSPageSize: true
})
expect(getSendCommandMethods(mock)).not.toContain('Page.printToPDF')
client.close()
})
it('keeps default PDF margins for omitted sides', async () => {
const client = await connect(endpoint)
await sendAndReceive(client, {
id: 20,
method: 'Page.printToPDF',
params: {
marginTop: 0.25
}
})
expect(mock.webContents.printToPDF).toHaveBeenCalledWith({
margins: {
marginType: 'custom',
top: 0.25,
bottom: defaultPdfMarginInches,
left: defaultPdfMarginInches,
right: defaultPdfMarginInches
}
})
client.close()
})
it('supports streamed Page.printToPDF results for Playwright page.pdf', async () => {
mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef'))
const client = await connect(endpoint)
const printResponse = await sendAndReceive(client, {
id: 21,
method: 'Page.printToPDF',
params: { transferMode: 'ReturnAsStream' }
})
const handle = expectPdfStreamHandle(printResponse)
const firstRead = await sendAndReceive(client, {
id: 22,
method: 'IO.read',
params: { handle, size: 2 }
})
const secondRead = await sendAndReceive(client, {
id: 23,
method: 'IO.read',
params: { handle }
})
const closeResponse = await sendAndReceive(client, {
id: 24,
method: 'IO.close',
params: { handle }
})
const readAfterClose = await sendAndReceive(client, {
id: 25,
method: 'IO.read',
params: { handle }
})
expect(printResponse.id).toBe(21)
expect(firstRead).toEqual({
id: 22,
result: { base64Encoded: true, data: Buffer.from('ab').toString('base64'), eof: false }
})
expect(secondRead).toEqual({
id: 23,
result: { base64Encoded: true, data: Buffer.from('cdef').toString('base64'), eof: true }
})
expect(closeResponse).toEqual({ id: 24, result: {} })
expect(readAfterClose).toEqual({
id: 25,
error: { code: -32000, message: 'Invalid stream handle' }
})
client.close()
})
it('clears streamed PDF data when the client disconnects', async () => {
mock.webContents.printToPDF.mockResolvedValueOnce(Buffer.from('abcdef'))
const client = await connect(endpoint)
const printResponse = await sendAndReceive(client, {
id: 26,
method: 'Page.printToPDF',
params: { transferMode: 'ReturnAsStream' }
})
const handle = expectPdfStreamHandle(printResponse)
expect(printResponse.id).toBe(26)
client.close()
await new Promise((resolve) => setTimeout(resolve, 10))
const nextClient = await connect(endpoint)
const staleRead = await sendAndReceive(nextClient, {
id: 27,
method: 'IO.read',
params: { handle }
})
expect(staleRead).toEqual({
id: 27,
error: { code: -32000, message: 'Invalid stream handle' }
})
nextClient.close()
})
it('does not register a PDF stream when the client disconnects mid-print', async () => {
let resolvePrint: (buf: Buffer<ArrayBuffer>) => void = () => {}
mock.webContents.printToPDF.mockImplementationOnce(
() =>
new Promise<Buffer<ArrayBuffer>>((resolve) => {
resolvePrint = resolve
})
)
const store = (proxy as unknown as { pdfStreams: { create: (b: Buffer) => string } }).pdfStreams
const createSpy = vi.spyOn(store, 'create')
const client = await connect(endpoint)
client.send(
JSON.stringify({
id: 30,
method: 'Page.printToPDF',
params: { transferMode: 'ReturnAsStream' }
})
)
await new Promise((resolve) => setTimeout(resolve, 10))
client.close()
await new Promise((resolve) => setTimeout(resolve, 10))
// Print resolves only after the client is gone: no stream must be created.
resolvePrint(Buffer.from('%PDF-late'))
await new Promise((resolve) => setTimeout(resolve, 10))
expect(createSpy).not.toHaveBeenCalled()
createSpy.mockRestore()
})
it('forwards non-PDF IO streams to the debugger', async () => {
mock.webContents.debugger.sendCommand
.mockResolvedValueOnce({ data: 'trace-data', eof: false })
.mockResolvedValueOnce({})
const client = await connect(endpoint)
const readResponse = await sendAndReceive(client, {
id: 28,
method: 'IO.read',
params: { handle: 'trace-stream', size: 64 }
})
const closeResponse = await sendAndReceive(client, {
id: 29,
method: 'IO.close',
params: { handle: 'trace-stream' }
})
expect(readResponse).toEqual({ id: 28, result: { data: 'trace-data', eof: false } })
expect(closeResponse).toEqual({ id: 29, result: {} })
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.read', {
handle: 'trace-stream',
size: 64
})
expect(mock.webContents.debugger.sendCommand).toHaveBeenCalledWith('IO.close', {
handle: 'trace-stream'
})
client.close()
})
// ── Page.frameNavigated interception ──
// ── Cleanup ──
it('detaches debugger and closes server on stop', async () => {
const client = await connect()
const client = await connect(endpoint)
await proxy.stop()
expect(mock.webContents.debugger.detach).toHaveBeenCalled()
@@ -513,7 +651,7 @@ describe('CdpWsProxy', () => {
})
it('detaches client websocket listeners after client close', async () => {
const client = await connect()
const client = await connect(endpoint)
const serverClient = (proxy as unknown as { client: WebSocket | null }).client
expect(serverClient).toBeTruthy()
const offSpy = vi.spyOn(serverClient!, 'off')
@@ -543,7 +681,7 @@ describe('CdpWsProxy', () => {
})
)
const client = await connect()
const client = await connect(endpoint)
client.send(JSON.stringify({ id: 1, method: 'Page.enable', params: {} }))
await new Promise((r) => setTimeout(r, 10))
+166
View File
@@ -3,12 +3,19 @@ import { WebSocketServer, WebSocket } from 'ws'
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http'
import type { WebContents } from 'electron'
import { captureScreenshot } from './cdp-screenshot'
import { buildPrintToPdfOptions, CdpPdfStreamStore } from './cdp-print-to-pdf'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000
export class CdpWsProxy {
// Why: holds each session's last DOM.focus params to replay right before the next
// Input.insertText, countering the native webContents.focus() that would blur the target.
private pendingDomFocusBySession = new Map<
string | undefined,
Promise<Record<string, unknown> | undefined>
>()
private httpServer: Server | null = null
private wss: WebSocketServer | null = null
private client: WebSocket | null = null
@@ -20,6 +27,7 @@ export class CdpWsProxy {
private attached = false
// Why: agent-browser filters events by sessionId from Target.attachToTarget.
private clientSessionId: string | undefined = undefined
private readonly pdfStreams = new CdpPdfStreamStore()
constructor(private readonly webContents: WebContents) {}
@@ -51,6 +59,7 @@ export class CdpWsProxy {
const onClose = (): void => {
detach()
if (this.client === ws) {
this.pdfStreams.clear()
this.client = null
}
}
@@ -101,6 +110,9 @@ export class CdpWsProxy {
this.detachClientListeners?.()
this.detachClientListeners = null
this.client = null
// Why: a pending focus belongs to the departing client; never replay it for the next one.
this.pendingDomFocusBySession.clear()
this.pdfStreams.clear()
client?.close()
}
@@ -278,6 +290,12 @@ export class CdpWsProxy {
)
return
}
const effectiveSessionId = this.resolveDebuggerSessionId(msg.sessionId)
// Why: a stored focus is only valid for the immediately following Input.insertText;
// any other command may have moved DOM focus, so invalidate the replay in one place.
if (msg.method !== 'DOM.focus' && msg.method !== 'Input.insertText') {
this.pendingDomFocusBySession.delete(effectiveSessionId)
}
if (msg.method === 'Page.bringToFront') {
if (!this.webContents.isDestroyed()) {
this.webContents.focus()
@@ -285,11 +303,39 @@ export class CdpWsProxy {
this.sendResult(clientId, {}, client)
return
}
if (msg.method === 'DOM.focus') {
this.forwardDomFocus(client, clientId, msg.params ?? {}, effectiveSessionId)
return
}
// Why: Page.captureScreenshot via debugger.sendCommand hangs on Electron webview guests.
if (msg.method === 'Page.captureScreenshot') {
this.handleScreenshot(client, clientId, msg.params)
return
}
// Why: CDP Page.printToPDF is not available for Electron webview guests.
// Electron's native printToPDF path is the reliable equivalent.
if (msg.method === 'Page.printToPDF') {
void this.handlePrintToPdf(client, clientId, msg.params ?? {})
return
}
if (msg.method === 'IO.read') {
const params = msg.params ?? {}
if (this.pdfStreams.ownsHandle(params)) {
this.handleStreamRead(client, clientId, params)
return
}
this.forwardCommand(client, clientId, msg.method, params, msg.sessionId)
return
}
if (msg.method === 'IO.close') {
const params = msg.params ?? {}
if (this.pdfStreams.ownsHandle(params)) {
this.handleStreamClose(client, clientId, params)
return
}
this.forwardCommand(client, clientId, msg.method, params, msg.sessionId)
return
}
// Why: Input.insertText can still require native focus in Electron webviews.
// Do not auto-focus generic Runtime.evaluate/callFunctionOn traffic: wait
// polling and read-only JS probes use those methods heavily, and focusing on
@@ -297,6 +343,8 @@ export class CdpWsProxy {
// is running.
if (msg.method === 'Input.insertText' && !this.webContents.isDestroyed()) {
this.webContents.focus()
void this.forwardInsertText(client, clientId, msg.params ?? {}, effectiveSessionId)
return
}
// Why: agent-browser waits for network idle to detect navigation completion.
// Electron webview CDP subscriptions silently lapse after cross-process swaps.
@@ -439,6 +487,124 @@ export class CdpWsProxy {
}
}
// Why: this must stay synchronous up to the `.set()` call so the pending-focus
// entry exists before the event loop can dispatch a pipelined Input.insertText
// message, closing the race where the replay would otherwise be silently skipped.
private forwardDomFocus(
client: WebSocket,
clientId: number,
params: Record<string, unknown>,
effectiveSessionId?: string
): void {
const focused = this.sendDomFocus(client, clientId, params, effectiveSessionId)
this.pendingDomFocusBySession.set(effectiveSessionId, focused)
}
private async sendDomFocus(
client: WebSocket,
clientId: number,
params: Record<string, unknown>,
effectiveSessionId?: string
): Promise<Record<string, unknown> | undefined> {
if (this.webContents.isDestroyed()) {
this.sendError(clientId, 'Browser tab is no longer available', client)
return undefined
}
try {
const result = await this.sendDebuggerCommand('DOM.focus', params, effectiveSessionId)
this.sendResult(clientId, result, client)
return { ...params }
} catch (err) {
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
return undefined
}
}
private async forwardInsertText(
client: WebSocket,
clientId: number,
params: Record<string, unknown>,
effectiveSessionId?: string
): Promise<void> {
const pendingFocus = this.pendingDomFocusBySession.get(effectiveSessionId)
this.pendingDomFocusBySession.delete(effectiveSessionId)
const pendingFocusParams = pendingFocus ? await pendingFocus : undefined
// Why: the client can disconnect while DOM.focus is in flight; don't replay its
// focus or forward its insert into the live page once it is no longer active.
if (!this.isActiveClient(client)) {
return
}
if (pendingFocusParams) {
if (this.webContents.isDestroyed()) {
this.sendError(clientId, 'Browser tab is no longer available', client)
return
}
try {
await this.sendDebuggerCommand('DOM.focus', pendingFocusParams, effectiveSessionId)
} catch (err) {
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
return
}
// Why: the replay DOM.focus also awaited a round-trip; bail if the client vanished
// during it so its insert never lands in the live page.
if (!this.isActiveClient(client)) {
return
}
}
this.forwardCommand(client, clientId, 'Input.insertText', params, effectiveSessionId)
}
private async handlePrintToPdf(
client: WebSocket,
clientId: number,
params: Record<string, unknown>
): Promise<void> {
if (this.webContents.isDestroyed()) {
this.sendError(clientId, 'Browser tab is no longer available', client)
return
}
try {
const pdf = await this.webContents.printToPDF(buildPrintToPdfOptions(params))
// Why: printToPDF can resolve after the client disconnected (or was
// replaced). Bail before registering a stream so its buffer isn't
// orphaned in pdfStreams past the disconnect's clear() until the TTL.
if (!this.isActiveClient(client)) {
return
}
const buffer = Buffer.isBuffer(pdf) ? pdf : Buffer.from(pdf)
if (params.transferMode === 'ReturnAsStream') {
const handle = this.pdfStreams.create(buffer)
this.sendResult(clientId, { data: '', stream: handle }, client)
return
}
this.sendResult(clientId, { data: buffer.toString('base64') }, client)
} catch (err) {
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
}
}
private handleStreamRead(
client: WebSocket,
clientId: number,
params: Record<string, unknown>
): void {
const chunk = this.pdfStreams.read(params)
if (!chunk) {
this.sendError(clientId, 'Invalid stream handle', client)
return
}
this.sendResult(clientId, { base64Encoded: true, data: chunk.data, eof: chunk.eof }, client)
}
private handleStreamClose(
client: WebSocket,
clientId: number,
params: Record<string, unknown>
): void {
this.pdfStreams.close(params)
this.sendResult(clientId, {}, client)
}
private handleScreenshot(
client: WebSocket,
clientId: number,
+6
View File
@@ -269,6 +269,12 @@ describe('ClaudeHookService.installRemote', () => {
const script = fs.files.get('/home/dev/.orca/agent-hooks/claude-hook.sh')
expect(script).toContain('#!/bin/sh')
expect(script).toContain('DEVIN_PROJECT_DIR')
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive),
// matching the Windows curl.exe hook post.
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
expect(fs.modes.get('/home/dev/.orca/agent-hooks/claude-hook.sh')).toBe(0o755)
})
+5 -2
View File
@@ -113,7 +113,10 @@ function getManagedScript(
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/claude" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -123,7 +126,7 @@ function getManagedScript(
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+1 -1
View File
@@ -32,6 +32,6 @@ ELECTRON_RUN_AS_NODE=1 exec "$APPIMAGE" -e ${quoteShell(APPIMAGE_CLI_SCRIPT)} --
`
}
function quoteShell(value: string): string {
export function quoteShell(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`
}
@@ -0,0 +1,130 @@
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { isPackaged: true }
}))
import { installLinuxBareOrcaDispatcher } from './linux-bare-orca-dispatcher'
const created: string[] = []
async function makeFixture(): Promise<{ homePath: string; resourcesPath: string }> {
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-'))
created.push(root)
const resourcesPath = join(root, 'resources')
// The bundled orca-ide launcher must exist for the dispatcher to be written.
await mkdir(join(resourcesPath, 'bin'), { recursive: true })
await writeFile(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8')
return { homePath: join(root, 'home'), resourcesPath }
}
afterEach(async () => {
await Promise.all(created.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})
describe('installLinuxBareOrcaDispatcher', () => {
it('writes an executable bare-orca dispatcher that execs the bundled orca-ide launcher', async () => {
const { homePath, resourcesPath } = await makeFixture()
const result = await installLinuxBareOrcaDispatcher({
resourcesPath,
homePath,
appImagePath: null
})
const expectedTarget = join(resourcesPath, 'bin', 'orca-ide')
expect(result.state).toBe('installed')
expect(result.target).toBe(expectedTarget)
expect(result.dispatcherPath).toBe(join(homePath, '.local', 'bin', 'orca'))
const content = await readFile(result.dispatcherPath, 'utf8')
expect(content).toContain('#!/usr/bin/env bash')
// Single-quoted so a resources path with shell metacharacters can't break out.
expect(content).toContain(`exec '${expectedTarget}' "$@"`)
const mode = (await stat(result.dispatcherPath)).mode & 0o777
expect(mode & 0o111).not.toBe(0)
})
it('is idempotent — a second install rewrites its own dispatcher without throwing', async () => {
const { homePath, resourcesPath } = await makeFixture()
const first = await installLinuxBareOrcaDispatcher({
resourcesPath,
homePath,
appImagePath: null
})
const second = await installLinuxBareOrcaDispatcher({
resourcesPath,
homePath,
appImagePath: null
})
expect(second).toEqual(first)
expect(second.state).toBe('installed')
})
it('quotes a resources path containing spaces so the exec line cannot be split', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-space-'))
created.push(root)
const resourcesPath = join(root, 'App Support', 'resources')
await mkdir(join(resourcesPath, 'bin'), { recursive: true })
await writeFile(join(resourcesPath, 'bin', 'orca-ide'), '#!/usr/bin/env bash\n', 'utf8')
const result = await installLinuxBareOrcaDispatcher({
resourcesPath,
homePath: join(root, 'home'),
appImagePath: null
})
const content = await readFile(result.dispatcherPath, 'utf8')
expect(content).toContain(`exec '${join(resourcesPath, 'bin', 'orca-ide')}' "$@"`)
})
it('execs the stable AppImage (not the ephemeral mount) when running from an AppImage', async () => {
const { homePath, resourcesPath } = await makeFixture()
const appImagePath = join(homePath, 'Applications', 'Orca.AppImage')
const result = await installLinuxBareOrcaDispatcher({ resourcesPath, homePath, appImagePath })
expect(result.state).toBe('installed')
expect(result.target).toBe(appImagePath)
const content = await readFile(result.dispatcherPath, 'utf8')
// The AppImage wrapper references the stable outer path, never resourcesPath.
expect(content).toContain(appImagePath)
expect(content).not.toContain(resourcesPath)
})
it('skips (does not clobber) a user-owned orca already at ~/.local/bin', async () => {
const { homePath, resourcesPath } = await makeFixture()
const dispatcherPath = join(homePath, '.local', 'bin', 'orca')
await mkdir(join(homePath, '.local', 'bin'), { recursive: true })
await writeFile(dispatcherPath, '#!/bin/sh\necho my own orca\n', 'utf8')
const result = await installLinuxBareOrcaDispatcher({
resourcesPath,
homePath,
appImagePath: null
})
expect(result.state).toBe('skipped-foreign')
expect(await readFile(dispatcherPath, 'utf8')).toBe('#!/bin/sh\necho my own orca\n')
})
it('skips when the bundled orca-ide launcher is missing from the build', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-bare-dispatcher-nolauncher-'))
created.push(root)
const result = await installLinuxBareOrcaDispatcher({
resourcesPath: join(root, 'resources'),
homePath: join(root, 'home'),
appImagePath: null
})
expect(result.state).toBe('skipped-launcher-missing')
expect(result.target).toBeNull()
})
})
+102
View File
@@ -0,0 +1,102 @@
import { existsSync } from 'node:fs'
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { buildAppImageCliWrapper, quoteShell } from './appimage-cli-wrapper'
import { getBundledLauncherPath } from './cli-installer'
// Why: marks a dispatcher this function wrote so repeat serve starts overwrite
// our own file idempotently but never clobber a user's own ~/.local/bin/orca.
const DISPATCHER_MARKER = '# orca-serve-bare-orca-dispatcher'
export type LinuxBareOrcaDispatcherOptions = {
/** Packaged app resources root; the bundled `orca-ide` launcher lives under it. */
resourcesPath: string
/** Test seam — defaults to the real home directory. */
homePath?: string
/** Test seam — defaults to $APPIMAGE (set only when running from an AppImage). */
appImagePath?: string | null
}
export type LinuxBareOrcaDispatcherState =
| 'installed'
| 'skipped-foreign'
| 'skipped-launcher-missing'
export type LinuxBareOrcaDispatcherResult = {
state: LinuxBareOrcaDispatcherState
dispatcherPath: string
/** What the dispatcher execs: the stable AppImage, or the bundled orca-ide. */
target: string | null
}
// Why: on Linux the CLI installs as `orca-ide`, not bare `orca`, to avoid
// shadowing GNOME Orca's /usr/bin/orca. But the Claude Team launcher typed into
// the initial managed terminal invokes the literal `orca claude-teams`, so a
// headless serve box needs a bare-`orca` dispatcher on the managed-terminal PATH
// (~/.local/bin, which patchPackagedProcessPath puts ahead of /usr/bin). It is a
// plain file, not a managed symlink, so CliInstaller.removeLegacyLinuxCommandIfManaged
// never reclaims it.
export async function installLinuxBareOrcaDispatcher(
options: LinuxBareOrcaDispatcherOptions
): Promise<LinuxBareOrcaDispatcherResult> {
const dispatcherPath = join(options.homePath ?? homedir(), '.local', 'bin', 'orca')
const appImagePath = options.appImagePath ?? process.env.APPIMAGE ?? null
const resolved = resolveDispatcherScript(options.resourcesPath, appImagePath)
if (!resolved) {
return { state: 'skipped-launcher-missing', dispatcherPath, target: null }
}
// Why: only (re)write a dispatcher we previously created; leave a user's own
// `orca` untouched rather than silently clobbering it on every serve start.
if (existsSync(dispatcherPath) && !(await isOwnedDispatcher(dispatcherPath))) {
return { state: 'skipped-foreign', dispatcherPath, target: resolved.target }
}
await mkdir(dirname(dispatcherPath), { recursive: true })
await writeFile(dispatcherPath, resolved.script, 'utf8')
await chmod(dispatcherPath, 0o755)
return { state: 'installed', dispatcherPath, target: resolved.target }
}
function resolveDispatcherScript(
resourcesPath: string,
appImagePath: string | null
): { script: string; target: string } | null {
if (appImagePath) {
// Why: an AppImage mounts resources under an ephemeral FUSE path per launch,
// so the dispatcher must exec the stable outer AppImage — reuse the same
// wrapper CliInstaller installs for the AppImage command.
return { script: withMarker(buildAppImageCliWrapper(appImagePath)), target: appImagePath }
}
const launcher = getBundledLauncherPath('linux', resourcesPath)
// Why: getBundledLauncherPath only joins the path; guard existence so we never
// write a dispatcher pointing at a missing launcher (which would fail at exec
// time with a confusing error instead of the command-not-found we fix).
if (!launcher || !existsSync(launcher)) {
return null
}
return {
script: `#!/usr/bin/env bash\n${DISPATCHER_MARKER}\nexec ${quoteShell(launcher)} "$@"\n`,
target: launcher
}
}
function withMarker(script: string): string {
const firstNewline = script.indexOf('\n')
if (firstNewline === -1) {
return `${script}\n${DISPATCHER_MARKER}\n`
}
// Keep the shebang on line 1; insert the marker immediately after it.
return `${script.slice(0, firstNewline + 1)}${DISPATCHER_MARKER}\n${script.slice(firstNewline + 1)}`
}
async function isOwnedDispatcher(dispatcherPath: string): Promise<boolean> {
try {
return (await readFile(dispatcherPath, 'utf8')).includes(DISPATCHER_MARKER)
} catch {
return false
}
}
+5 -2
View File
@@ -712,7 +712,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -722,7 +725,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
@@ -121,7 +121,10 @@ export function buildCommandCodeManagedScript(
' exit 0',
'fi',
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/command-code" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/command-code" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -131,7 +134,7 @@ export function buildCommandCodeManagedScript(
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+5 -2
View File
@@ -166,7 +166,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
'if [ -z "$payload" ]; then',
' exit 0',
'fi',
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -177,7 +180,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+5
View File
@@ -77,7 +77,12 @@ describe('CursorHookService', () => {
if (process.platform === 'win32') {
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
} else {
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive).
expect(script).toContain('payload=$(cat)')
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
}
})
+5 -2
View File
@@ -104,7 +104,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/cursor" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -114,7 +117,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+5
View File
@@ -64,6 +64,11 @@ describe('DevinHookService', () => {
}
const script = readFileSync(getDevinManagedScriptPath(), 'utf8')
expect(script).toContain('/hook/devin')
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive).
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
})
it('preserves unrelated keys in Devin config when installing hooks', () => {
+5 -2
View File
@@ -80,7 +80,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/devin" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -90,7 +93,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+5 -2
View File
@@ -92,7 +92,10 @@ function getManagedScript(): string {
' exit 0',
'fi',
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/droid" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -102,7 +105,7 @@ function getManagedScript(): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+5 -2
View File
@@ -97,7 +97,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
// shell is not safe once a path contains quotes or newlines. Post the raw
// hook payload plus metadata as form fields and let the receiver parse it.
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/gemini" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -107,7 +110,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+263
View File
@@ -1,7 +1,25 @@
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { extractExecError, ghExecFileAsync, gitExecFileAsync } from './runner'
import { parseHostedRemote } from './hosted-remote-url'
import { resolveDefaultBaseRefViaExec } from './repo'
const EXPLICIT_USERNAME_CONFIG_KEYS = ['github.user', 'user.username'] as const
const GH_LOGIN_PROBE_TIMEOUT_MS = 2500
// Why: a timeout-killed gh can leave a grandchild holding the stdio pipes, so
// the exec promise may settle long after the kill. The wall keeps the resolver
// on schedule either way (issue #7225: a hung gh froze startup for 127s). It
// must exceed ghExecFileAsync's full worst-case envelope — 3 attempts ×
// GH_LOGIN_PROBE_TIMEOUT_MS plus its transient-retry backoff sleeps (250ms +
// 1000ms ≈ 8.75s total) — or a slow-but-recovering gh would be misread as
// stuck and start the retry cooldown. Retry-After sleeps can exceed any wall;
// bounding those is exactly what the wall is for.
const GH_LOGIN_PROBE_WALL_MS = 10_000
// Why: a timed-out probe says nothing about the account, so don't pin '' for
// the whole session — retry after a cooldown instead of hammering a stuck gh.
const GH_LOGIN_TIMEOUT_RETRY_MS = 5 * 60 * 1000
const LOCAL_GIT_READ_TIMEOUT_MS = 5000
export function normalizeGitUsername(value: string): string {
const trimmed = value.trim()
if (!trimmed) {
@@ -12,6 +30,13 @@ export function normalizeGitUsername(value: string): string {
return localPart.replace(/^\d+\+/, '')
}
/**
* A resolved username plus whether every probe on the way to it completed.
* Non-authoritative '' (a probe timed out) must not overwrite a previously
* persisted username; authoritative '' should clear one.
*/
export type ResolvedGitUsername = { username: string; authoritative: boolean }
export async function getSshGitUsername(
provider: SshGitProvider,
repoPath: string
@@ -31,3 +56,241 @@ export async function getSshGitUsername(
}
return ''
}
type GhLoginProbeResult = { stdout: string; stderr: string; timedOut: boolean }
type GhLoginOutcome = { login: string; timedOut: boolean }
// gh reports one account for the whole machine, so the login is cached
// per-process rather than per-repo (mirrors the old sync cache). Timed-out
// probes use the soft retry timestamp instead of the permanent cache.
let cachedGhLogin: string | null = null
let ghLoginTimedOutAt: number | null = null
let ghLoginProbeInFlight: Promise<GhLoginOutcome> | null = null
function isExecTimeoutError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false
}
const { code, killed, signal } = err as { code?: unknown; killed?: unknown; signal?: unknown }
// Why: on Windows a timeout kill surfaces as killed/SIGTERM with a null
// code, not ETIMEDOUT — the old ETIMEDOUT-only check let a stuck first
// probe fall through to a second equally stuck probe (issue #7225).
return code === 'ETIMEDOUT' || killed === true || signal === 'SIGTERM'
}
async function runGhLoginProbe(args: string[]): Promise<GhLoginProbeResult> {
let wallTimer: ReturnType<typeof setTimeout> | undefined
const wall = new Promise<GhLoginProbeResult>((resolve) => {
wallTimer = setTimeout(
() => resolve({ stdout: '', stderr: '', timedOut: true }),
GH_LOGIN_PROBE_WALL_MS
)
wallTimer.unref?.()
})
const exec = ghExecFileAsync(args, { timeout: GH_LOGIN_PROBE_TIMEOUT_MS }).then(
({ stdout, stderr }) => ({ stdout, stderr, timedOut: false }),
(err: unknown) => {
// Why: `gh auth status` reports the login on stderr with a non-zero
// exit when partially authenticated, so failures still carry output.
const { stdout, stderr } = extractExecError(err)
return { stdout, stderr, timedOut: isExecTimeoutError(err) }
}
)
try {
return await Promise.race([exec, wall])
} finally {
if (wallTimer) {
clearTimeout(wallTimer)
}
}
}
// Why: `gh auth status` prints one block per account — the login line first,
// then `Active account: true/false`. Parse block-wise so a multi-account
// output resolves the ACTIVE account instead of whatever login line happens
// to follow the first `Active account: true` marker.
function parseGhAuthStatusLogin(output: string): string {
let currentLogin = ''
let firstLogin = ''
for (const line of output.split('\n')) {
const login = line.match(/Logged in to github\.com account\s+([A-Za-z0-9-]+)/)?.[1]
if (login) {
currentLogin = login
if (!firstLogin) {
firstLogin = login
}
continue
}
if (/Active account:\s+true/.test(line) && currentLogin) {
return currentLogin
}
}
return firstLogin
}
async function probeGhLoginOnce(): Promise<GhLoginOutcome> {
const api = await runGhLoginProbe(['api', 'user', '-q', '.login'])
const apiLogin = normalizeGitUsername(api.stdout.trim())
if (apiLogin) {
return { login: apiLogin, timedOut: false }
}
if (api.timedOut) {
// Why: if `gh api user` timed out, `gh auth status` is likely to hit the
// same stuck keychain/network path. Keep resolution bounded to one probe.
return { login: '', timedOut: true }
}
const status = await runGhLoginProbe(['auth', 'status'])
if (status.timedOut) {
return { login: '', timedOut: true }
}
const output = `${status.stdout}\n${status.stderr}`
return { login: normalizeGitUsername(parseGhAuthStatusLogin(output)), timedOut: false }
}
async function getGhLoginOutcome(): Promise<GhLoginOutcome> {
if (cachedGhLogin !== null) {
return { login: cachedGhLogin, timedOut: false }
}
if (ghLoginTimedOutAt !== null && Date.now() - ghLoginTimedOutAt < GH_LOGIN_TIMEOUT_RETRY_MS) {
return { login: '', timedOut: true }
}
if (ghLoginProbeInFlight) {
return ghLoginProbeInFlight
}
const probe = probeGhLoginOnce()
.then((outcome) => {
if (outcome.timedOut) {
ghLoginTimedOutAt = Date.now()
} else {
cachedGhLogin = outcome.login
ghLoginTimedOutAt = null
}
return outcome
})
.finally(() => {
ghLoginProbeInFlight = null
})
ghLoginProbeInFlight = probe
return probe
}
/**
* Resolve the `gh` CLI's GitHub login without ever blocking the caller for
* longer than the probe wall. Never rejects; unknown resolves to ''.
*/
export async function getGhLoginAsync(): Promise<string> {
return (await getGhLoginOutcome()).login
}
async function readGitStdout(repoPath: string, args: string[]): Promise<string> {
try {
const { stdout } = await gitExecFileAsync(args, {
cwd: repoPath,
timeout: LOCAL_GIT_READ_TIMEOUT_MS
})
return stdout.trim()
} catch {
return ''
}
}
function getRemoteNameFromRef(shortRef: string, remotes: readonly string[]): string {
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
return sortedRemotes.find((remote) => shortRef.startsWith(`${remote}/`)) ?? ''
}
function getDefaultBranchName(shortRef: string, remoteName: string): string {
if (!shortRef.includes('/')) {
return shortRef
}
return remoteName ? shortRef.slice(remoteName.length + 1) : shortRef.split('/').slice(1).join('/')
}
async function getConfiguredBranchRemote(repoPath: string, branch: string | null): Promise<string> {
if (!branch) {
return ''
}
const remote = await readGitStdout(repoPath, ['config', '--get', `branch.${branch}.remote`])
return remote === '.' ? '' : remote
}
/**
* Faithful async port of the old candidate-ordered GitHub gate: only the
* repo's *effective* remote (current-branch remote, default-branch remote,
* default-base remote, origin, or a lone remote) may authorize the gh login.
* Why: a GitLab-primary repo with a secondary GitHub mirror must NOT pick up
* the GitHub account name as its branch prefix.
*/
async function localRepoHasEffectiveGitHubRemote(repoPath: string): Promise<boolean> {
const remotes = (await readGitStdout(repoPath, ['remote'])).split('\n').filter(Boolean)
const defaultBaseRef = await resolveDefaultBaseRefViaExec((argv) =>
gitExecFileAsync(argv, { cwd: repoPath, timeout: LOCAL_GIT_READ_TIMEOUT_MS })
)
const defaultBaseRemote = defaultBaseRef ? getRemoteNameFromRef(defaultBaseRef, remotes) : ''
const defaultBranch = defaultBaseRef
? getDefaultBranchName(defaultBaseRef, defaultBaseRemote)
: null
const currentBranch = await readGitStdout(repoPath, ['branch', '--show-current'])
const candidateRemotes = [
await getConfiguredBranchRemote(repoPath, currentBranch || null),
await getConfiguredBranchRemote(repoPath, defaultBranch),
defaultBaseRemote,
'origin',
remotes.length === 1 ? remotes[0] : ''
]
const seen = new Set<string>()
for (const remote of candidateRemotes) {
if (!remote || seen.has(remote)) {
continue
}
seen.add(remote)
const remoteUrl = await readGitStdout(repoPath, ['remote', 'get-url', remote])
if (remoteUrl && parseHostedRemote(remoteUrl)?.provider === 'github') {
return true
}
}
return false
}
/**
* Async replacement for the old sync `getGitUsername`: explicit config keys
* first, then the `gh` login — but only for repos whose effective remote is
* GitHub, since a GitHub account name would be the wrong branch prefix for
* GitLab/Bitbucket/self-hosted repos. Never rejects; unknown resolves to
* { username: '', authoritative: false }.
*/
export async function resolveLocalGitUsernameDetailed(
repoPath: string
): Promise<ResolvedGitUsername> {
for (const key of EXPLICIT_USERNAME_CONFIG_KEYS) {
try {
const { stdout } = await gitExecFileAsync(['config', '--get', key], {
cwd: repoPath,
timeout: LOCAL_GIT_READ_TIMEOUT_MS
})
const username = normalizeGitUsername(stdout)
if (username) {
return { username, authoritative: true }
}
} catch {
// Missing config keys are expected; try the next explicit username key.
}
}
if (await localRepoHasEffectiveGitHubRemote(repoPath)) {
const outcome = await getGhLoginOutcome()
return { username: outcome.login, authoritative: !outcome.timedOut }
}
return { username: '', authoritative: true }
}
export async function resolveLocalGitUsername(repoPath: string): Promise<string> {
return (await resolveLocalGitUsernameDetailed(repoPath)).username
}
export function resetGhLoginCacheForTests(): void {
cachedGhLogin = null
ghLoginTimedOutAt = null
ghLoginProbeInFlight = null
}
+191 -80
View File
@@ -1,160 +1,271 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ChildProcess from 'node:child_process'
import type * as RepoModule from './repo'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RunnerModule from './runner'
const execSyncMock = vi.hoisted(() => vi.fn())
const execFileSyncMock = vi.hoisted(() => vi.fn())
const gitExecFileAsyncMock = vi.hoisted(() => vi.fn())
const ghExecFileAsyncMock = vi.hoisted(() => vi.fn())
vi.mock('child_process', async () => {
const actual = await vi.importActual<typeof ChildProcess>('child_process')
vi.mock('./runner', async () => {
const actual = await vi.importActual<typeof RunnerModule>('./runner')
return {
...actual,
execSync: execSyncMock,
execFileSync: execFileSyncMock
gitExecFileAsync: gitExecFileAsyncMock,
ghExecFileAsync: ghExecFileAsyncMock
}
})
describe('getGitUsername', () => {
import {
resolveLocalGitUsername,
resolveLocalGitUsernameDetailed,
resetGhLoginCacheForTests
} from './git-username'
function makeExecError(
message: string,
extra: { code?: string; killed?: boolean; signal?: string; stderr?: string } = {}
): Error {
return Object.assign(new Error(message), { stdout: '', stderr: '', ...extra })
}
describe('resolveLocalGitUsername', () => {
let gitConfig: Record<string, string>
let originRemoteUrl: string | undefined
let remoteUrls: Record<string, string>
let getGitUsername: typeof RepoModule.getGitUsername
let currentBranch: string
beforeEach(async () => {
vi.resetModules()
execSyncMock.mockReset()
execFileSyncMock.mockReset()
beforeEach(() => {
vi.resetAllMocks()
resetGhLoginCacheForTests()
gitConfig = {}
originRemoteUrl = undefined
remoteUrls = {}
currentBranch = ''
execFileSyncMock.mockImplementation((_binary: string, args: string[]) => {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'config' && args[1] === '--get') {
const value = gitConfig[args[2]]
if (value !== undefined) {
return `${value}\n`
return { stdout: `${value}\n`, stderr: '' }
}
throw new Error(`missing config ${args[2]}`)
throw makeExecError(`missing config ${args[2]}`)
}
if (args[0] === 'remote' && args.length === 1) {
const remotes = new Set(Object.keys(remoteUrls))
if (originRemoteUrl) {
remotes.add('origin')
}
return `${[...remotes].join('\n')}\n`
return { stdout: `${[...remotes].join('\n')}\n`, stderr: '' }
}
if (args[0] === 'remote' && args[1] === 'get-url') {
const remoteUrl = args[2] === 'origin' ? originRemoteUrl : remoteUrls[args[2]]
if (remoteUrl) {
return `${remoteUrl}\n`
return { stdout: `${remoteUrl}\n`, stderr: '' }
}
throw new Error(`missing ${args[2]} remote`)
throw makeExecError(`missing ${args[2]} remote`)
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
if (args[0] === 'branch' && args[1] === '--show-current') {
return { stdout: `${currentBranch}\n`, stderr: '' }
}
if (args[0] === 'symbolic-ref') {
// origin/HEAD unset — resolveDefaultBaseRefViaExec falls through to probes.
throw makeExecError('no origin/HEAD')
}
if (args[0] === 'rev-parse') {
throw makeExecError(`missing ref ${args.at(-1)}`)
}
throw makeExecError(`unexpected git args: ${args.join(' ')}`)
})
;({ getGitUsername } = await import('./repo'))
})
it('prefers explicit GitHub user config before checking GitHub CLI login', () => {
afterEach(() => {
vi.useRealTimers()
})
it('prefers explicit GitHub user config before checking GitHub CLI login', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
gitConfig['github.user'] = 'config-demo'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('config-demo')
expect(execSyncMock).not.toHaveBeenCalled()
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('config-demo')
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('uses explicit username config before checking GitHub CLI login', () => {
it('uses explicit username config before checking GitHub CLI login', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
gitConfig['user.username'] = 'repo-demo'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('repo-demo')
expect(execSyncMock).not.toHaveBeenCalled()
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('repo-demo')
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('uses GitHub CLI login for GitHub remotes instead of repo-local author identity', () => {
it('uses GitHub CLI login for GitHub remotes instead of repo-local author identity', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
gitConfig['user.email'] = 'demo@example.com'
gitConfig['user.name'] = 'Demo User'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('gh-demo')
expect(execSyncMock).toHaveBeenCalledTimes(1)
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('uses GitHub CLI login for a single GitHub remote not named origin', () => {
it('uses GitHub CLI login for a single GitHub remote not named origin', async () => {
remoteUrls.upstream = 'https://github.com/stablyai/orca.git'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('gh-demo')
expect(execFileSyncMock.mock.calls).toEqual(
expect.arrayContaining([
['git', ['remote', 'get-url', 'upstream'], expect.objectContaining({ cwd: '/repo' })]
])
)
expect(execSyncMock).toHaveBeenCalledTimes(1)
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('uses GitHub CLI login for GitHub SSH-over-443 remotes', () => {
it('uses GitHub CLI login for GitHub SSH-over-443 remotes', async () => {
remoteUrls.upstream = 'ssh://git@ssh.github.com:443/stablyai/orca.git'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('gh-demo')
expect(execSyncMock).toHaveBeenCalledTimes(1)
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('gh-demo')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('does not derive GitHub username prefixes from non-GitHub author identity', () => {
it('does not derive GitHub username prefixes from non-GitHub remotes', async () => {
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
gitConfig['user.email'] = 'demo@example.com'
gitConfig['user.name'] = 'Demo User'
execSyncMock.mockImplementationOnce(() => 'gh-demo\n')
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
expect(getGitUsername('/repo')).toBe('')
expect(execSyncMock).not.toHaveBeenCalled()
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('bounds and caches failed GitHub CLI lookup', () => {
it('ignores a secondary GitHub mirror when the effective remote is GitLab', async () => {
// Why: a GitLab-primary repo with a GitHub mirror must not pick up the
// GitHub account name as its branch prefix — only the effective remote
// (branch remote / default base remote / origin / lone remote) counts.
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
remoteUrls['github-mirror'] = 'https://github.com/stablyai/orca.git'
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('bounds and caches failed GitHub CLI lookup', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
execSyncMock.mockImplementation(() => {
throw new Error('gh unavailable')
})
ghExecFileAsyncMock.mockRejectedValue(makeExecError('gh unavailable'))
expect(getGitUsername('/repo')).toBe('')
expect(getGitUsername('/repo')).toBe('')
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
expect(execSyncMock).toHaveBeenCalledTimes(2)
for (const [, options] of execSyncMock.mock.calls) {
// api + auth-status fallback on the first resolution; cached afterwards.
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
for (const [, options] of ghExecFileAsyncMock.mock.calls) {
expect(options).toMatchObject({ timeout: 2500 })
}
})
it('skips auth status fallback when GitHub CLI API lookup times out', () => {
it('skips auth status fallback when GitHub CLI API lookup times out', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
execSyncMock.mockImplementationOnce(() => {
throw Object.assign(new Error('spawnSync /bin/sh ETIMEDOUT'), { code: 'ETIMEDOUT' })
})
ghExecFileAsyncMock.mockRejectedValueOnce(
makeExecError('spawnSync gh ETIMEDOUT', { code: 'ETIMEDOUT' })
)
expect(getGitUsername('/repo')).toBe('')
expect(getGitUsername('/repo')).toBe('')
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
expect(execSyncMock).toHaveBeenCalledTimes(1)
expect(execSyncMock.mock.calls[0][1]).toMatchObject({ timeout: 2500 })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('uses auth status fallback after fast GitHub CLI API failure', () => {
it('treats a Windows timeout kill (SIGTERM, no ETIMEDOUT code) as a timeout', async () => {
// Why: on Windows the exec timeout kill surfaces killed/SIGTERM without an
// ETIMEDOUT code; the old sync probe missed this and ran a second equally
// stuck probe (issue #7225).
originRemoteUrl = 'https://github.com/stablyai/orca.git'
execSyncMock
.mockImplementationOnce(() => {
throw new Error('gh api unavailable')
})
.mockImplementationOnce(
() =>
'github.com\n ✓ Logged in to github.com account demo-user\n - Active account: true\n'
)
ghExecFileAsyncMock.mockRejectedValueOnce(
makeExecError('gh was killed', { killed: true, signal: 'SIGTERM' })
)
expect(getGitUsername('/repo')).toBe('demo-user')
expect(execSyncMock).toHaveBeenCalledTimes(2)
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('marks a timed-out gh probe non-authoritative and retries after the cooldown', async () => {
vi.useFakeTimers()
originRemoteUrl = 'https://github.com/stablyai/orca.git'
ghExecFileAsyncMock
.mockRejectedValueOnce(makeExecError('gh timeout', { code: 'ETIMEDOUT' }))
.mockResolvedValueOnce({ stdout: 'gh-demo\n', stderr: '' })
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
username: '',
authoritative: false
})
// Within the cooldown the timeout result is reused without a new spawn.
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
username: '',
authoritative: false
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
username: 'gh-demo',
authoritative: true
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('reports authoritative empty for non-GitHub repos', async () => {
originRemoteUrl = 'https://gitlab.com/stablyai/orca.git'
await expect(resolveLocalGitUsernameDetailed('/repo')).resolves.toEqual({
username: '',
authoritative: true
})
})
it('uses auth status fallback after fast GitHub CLI API failure', async () => {
originRemoteUrl = 'https://github.com/stablyai/orca.git'
ghExecFileAsyncMock
.mockRejectedValueOnce(makeExecError('gh api unavailable'))
.mockResolvedValueOnce({
stdout: '',
stderr:
'github.com\n ✓ Logged in to github.com account demo-user\n - Active account: true\n'
})
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('demo-user')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('settles within the wall even when the gh child never exits', async () => {
vi.useFakeTimers()
originRemoteUrl = 'https://github.com/stablyai/orca.git'
// A promise that never settles — models a killed gh whose grandchild
// keeps the stdio pipes open past the exec timeout.
ghExecFileAsyncMock.mockImplementation(() => new Promise(() => {}))
const resolution = resolveLocalGitUsernameDetailed('/repo')
await vi.advanceTimersByTimeAsync(10_100)
await expect(resolution).resolves.toEqual({ username: '', authoritative: false })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('picks the active account from a multi-account auth status output', async () => {
// Why: each account block prints its login line BEFORE its
// "Active account" marker; a cross-block regex would capture the next
// block's login instead of the active one.
originRemoteUrl = 'https://github.com/stablyai/orca.git'
ghExecFileAsyncMock
.mockRejectedValueOnce(makeExecError('gh api unavailable'))
.mockResolvedValueOnce({
stdout: '',
stderr: [
'github.com',
' ✓ Logged in to github.com account active-user (keyring)',
' - Active account: true',
' - Git operations protocol: https',
' ✓ Logged in to github.com account inactive-user (keyring)',
' - Active account: false'
].join('\n')
})
await expect(resolveLocalGitUsername('/repo')).resolves.toBe('active-user')
})
})
+1 -186
View File
@@ -1,5 +1,4 @@
/* oxlint-disable max-lines */
import { execSync } from 'node:child_process'
import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import { gitExecFileSync, gitExecFileAsync } from './runner'
@@ -8,14 +7,7 @@ import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-outp
import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { toWindowsWslPath } from '../wsl'
import {
buildHostedRemoteCommitUrl,
buildHostedRemoteFileUrl,
parseHostedRemote
} from './hosted-remote-url'
import { normalizeGitUsername } from './git-username'
const GH_LOGIN_TIMEOUT_MS = 2500
import { buildHostedRemoteCommitUrl, buildHostedRemoteFileUrl } from './hosted-remote-url'
type LocalGitExecOptions = {
wslDistro?: string
@@ -464,183 +456,6 @@ function getRemoteUrlByName(path: string, remote: string): string {
}).trim()
}
function listRemoteNamesSync(path: string): string[] {
try {
return gitExecFileSync(['remote'], { cwd: path })
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}
function getConfiguredBranchRemote(path: string, branch: string | null): string {
if (!branch) {
return ''
}
const remote = getGitConfigValue(path, `branch.${branch}.remote`)
return remote === '.' ? '' : remote
}
function getCurrentBranchName(path: string): string {
try {
return gitExecFileSync(['branch', '--show-current'], { cwd: path }).trim()
} catch {
return ''
}
}
function getRemoteNameFromRef(shortRef: string, remotes: readonly string[]): string {
const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length)
return sortedRemotes.find((remote) => shortRef.startsWith(`${remote}/`)) ?? ''
}
function getDefaultBranchName(shortRef: string, remoteName: string): string {
if (!shortRef.includes('/')) {
return shortRef
}
return remoteName ? shortRef.slice(remoteName.length + 1) : shortRef.split('/').slice(1).join('/')
}
function getGitConfigValue(path: string, key: string): string {
try {
return gitExecFileSync(['config', '--get', key], {
cwd: path
}).trim()
} catch {
return ''
}
}
let cachedGhLogin: string | undefined
function isGhProbeTimeout(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false
}
const err = error as { code?: unknown; message?: unknown }
return (
err.code === 'ETIMEDOUT' ||
(typeof err.message === 'string' && /\bETIMEDOUT\b|timed out/i.test(err.message))
)
}
function getGhLogin(): string {
if (cachedGhLogin !== undefined) {
return cachedGhLogin
}
try {
const apiLogin = execSync('gh api user -q .login', {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: GH_LOGIN_TIMEOUT_MS
}).trim()
if (apiLogin) {
cachedGhLogin = normalizeGitUsername(apiLogin)
return cachedGhLogin
}
} catch (err) {
if (isGhProbeTimeout(err)) {
// Why: if `gh api user` timed out, `gh auth status` is likely to hit the
// same stuck keychain/network path. Keep repo creation bounded to one probe.
cachedGhLogin = ''
return ''
}
// Fall through to auth status parsing
}
try {
// Why: gh auth status writes to stderr; redirect via shell so we can capture it.
// Use platform-appropriate shell — /bin/bash does not exist on Windows.
const output = execSync('gh auth status 2>&1', {
encoding: 'utf-8',
shell: process.platform === 'win32' ? process.env.ComSpec || 'cmd.exe' : '/bin/bash',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: GH_LOGIN_TIMEOUT_MS
})
const activeAccountMatch = output.match(
/Active account:\s+true[\s\S]*?account\s+([A-Za-z0-9-]+)/
)
if (activeAccountMatch?.[1]) {
cachedGhLogin = normalizeGitUsername(activeAccountMatch[1])
return cachedGhLogin
}
const accountMatch = output.match(/Logged in to github\.com account\s+([A-Za-z0-9-]+)/)
const login = normalizeGitUsername(accountMatch?.[1] ?? '')
if (login) {
cachedGhLogin = login
}
return login
} catch {
// Why: broken tokens/keychains can block the Electron main process.
// Keep the fallback best-effort for this app session.
cachedGhLogin = ''
return ''
}
}
function getGhLoginForGitHubRemote(path: string): string {
const remoteUrl = getGitHubRemoteUrlForGhLogin(path)
if (!remoteUrl) {
return ''
}
return getGhLogin()
}
function getGitHubRemoteUrlForGhLogin(path: string): string {
const remotes = listRemoteNamesSync(path)
const defaultBaseRef = getDefaultBaseRef(path)
const defaultBaseRemote = defaultBaseRef ? getRemoteNameFromRef(defaultBaseRef, remotes) : ''
const defaultBranch = defaultBaseRef
? getDefaultBranchName(defaultBaseRef, defaultBaseRemote)
: null
const candidateRemotes = [
getConfiguredBranchRemote(path, getCurrentBranchName(path)),
getConfiguredBranchRemote(path, defaultBranch),
defaultBaseRemote,
'origin',
remotes.length === 1 ? remotes[0] : ''
]
const seen = new Set<string>()
for (const remote of candidateRemotes) {
if (!remote || seen.has(remote)) {
continue
}
seen.add(remote)
try {
const remoteUrl = getRemoteUrlByName(path, remote)
if (parseHostedRemote(remoteUrl)?.provider === 'github') {
return remoteUrl
}
} catch {
// Missing candidate remotes are expected; try the next repo-level fallback.
}
}
// Why: `gh` reports a GitHub account. For GitLab/Bitbucket/self-hosted
// repos, using that identity would create the wrong provider prefix.
return ''
}
/**
* Get the GitHub/explicit username-style branch prefix for the repo.
*/
export function getGitUsername(path: string): string {
// Why: this backs the "Git Username" branch-prefix setting. Commit author
// email/name are not hosted-account usernames, so keep them out of this path.
return normalizeGitUsername(
getGitConfigValue(path, 'github.user') ||
getGitConfigValue(path, 'user.username') ||
getGhLoginForGitHubRemote(path)
)
}
function hasGitRef(path: string, ref: string): boolean {
try {
gitExecFileSync(['rev-parse', '--verify', ref], {
+41 -1
View File
@@ -2,7 +2,47 @@
// (transient detection must propagate, not silently retry on 250ms cadence)
// and stderr extraction from execFile rejections (err.message is unreliable).
import { afterEach, describe, expect, it, vi } from 'vitest'
import { extractExecError, isTransientGhError, parseRetryAfterMs } from './runner'
import {
extractExecError,
isTransientGhError,
parseRetryAfterMs,
redirectPortedHostnameToEnv
} from './runner'
describe('redirectPortedHostnameToEnv', () => {
it('moves a ported --hostname into GITLAB_HOST and strips the flag', () => {
const { args, options } = redirectPortedHostnameToEnv(
['api', '--hostname', 'gitlab.example.com:8443', 'projects/foo%2Fbar/issues'],
{ cwd: '/repo' }
)
expect(args).toEqual(['api', 'projects/foo%2Fbar/issues'])
expect(options.env?.GITLAB_HOST).toBe('gitlab.example.com:8443')
expect(options.cwd).toBe('/repo')
})
it('leaves a port-less --hostname untouched', () => {
const input = ['api', '--hostname', 'gitlab.com', 'user']
const { args, options } = redirectPortedHostnameToEnv(input, {})
expect(args).toEqual(input)
expect(options.env).toBeUndefined()
})
it('is a no-op when no --hostname is present', () => {
const input = ['auth', 'status']
const { args, options } = redirectPortedHostnameToEnv(input, { env: { A: '1' } })
expect(args).toEqual(input)
expect(options.env).toEqual({ A: '1' })
})
it('preserves existing env entries alongside GITLAB_HOST', () => {
const { options } = redirectPortedHostnameToEnv(
['auth', 'status', '--hostname', 'gl.example.org:3001'],
{ env: { PATH: '/usr/bin' } }
)
expect(options.env?.PATH).toBe('/usr/bin')
expect(options.env?.GITLAB_HOST).toBe('gl.example.org:3001')
})
})
afterEach(() => {
vi.restoreAllMocks()
+40 -1
View File
@@ -976,6 +976,13 @@ export async function gitStreamStdout(
})
}
// Why: sync git calls run on the Electron main thread. Local git is normally
// fast, but a repo on a dead network drive / cloud-placeholder path can hang
// git on filesystem timeouts for minutes with no timeout set — the leading
// explanation for issue #7225's 127s "Not Responding" freeze. Callers needing
// longer operations should use the async runners instead.
const GIT_EXEC_SYNC_TIMEOUT_MS = 15_000
/**
* Sync git command execution. Drop-in replacement for
* `execFileSync('git', args, { cwd, encoding, ... })`.
@@ -988,13 +995,15 @@ export function gitExecFileSync(
cwd: string
encoding?: BufferEncoding
stdio?: SpawnOptions['stdio']
timeout?: number
}
): string {
const resolved = resolveCommand('git', args, options.cwd)
return execFileSync(resolved.binary, resolved.args, {
cwd: resolved.cwd,
encoding: options.encoding ?? 'utf-8',
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe']
stdio: options.stdio ?? ['pipe', 'pipe', 'pipe'],
timeout: options.timeout ?? GIT_EXEC_SYNC_TIMEOUT_MS
}) as string
}
@@ -1409,10 +1418,40 @@ type GlabExecOptions = Omit<GitExecOptions, 'cwd'> & {
*
* Retry policy mirrors ghExecFileAsync.
*/
/**
* glab's `--hostname` flag rejects a host that carries a port
* ("error parsing --hostname: invalid hostname"). A self-hosted GitLab on a
* non-default port (e.g. `gitlab.example.com:8443`) must instead be selected
* via the `GITLAB_HOST` env var, which accepts `host:port`. Translate any
* `--hostname host:port` pair into `GITLAB_HOST` so every call site (`api`,
* `auth status`, …) works against ported self-hosted instances. Port-less
* `--hostname` values are left untouched.
*
* @internal exported for tests.
*/
export function redirectPortedHostnameToEnv(
args: string[],
options: GlabExecOptions
): { args: string[]; options: GlabExecOptions } {
const i = args.indexOf('--hostname')
if (i === -1 || i + 1 >= args.length) {
return { args, options }
}
const host = args[i + 1]
if (!/^[^/\s]+:\d+$/.test(host)) {
return { args, options }
}
return {
args: [...args.slice(0, i), ...args.slice(i + 2)],
options: { ...options, env: { ...(options.env ?? process.env), GITLAB_HOST: host } }
}
}
export async function glabExecFileAsync(
args: string[],
options: GlabExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
;({ args, options } = redirectPortedHostnameToEnv(args, options))
let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
let lastError: unknown
let attemptedDefaultWslFallback = false
+168 -1
View File
@@ -2,7 +2,7 @@
owner reset safety, dirty worktree, diverged branch, custom remote) that each
need dedicated test coverage. Splitting into separate files would scatter related tests
without a meaningful boundary. */
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted(
() => ({
@@ -22,11 +22,80 @@ import {
addSparseWorktree,
addWorktree,
listWorktreeGraph,
listWorktrees,
moveWorktree,
parseWorktreeList,
removeWorktree
} from './worktree'
describe('listWorktrees in-flight sharing', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
})
// Later describes in this file assume a pristine mock (no global reset).
afterEach(() => {
gitExecFileAsyncMock.mockReset()
})
it('shares one scan across concurrent calls for the same repo', async () => {
let resolveScan!: () => void
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolveScan = () => resolve({ stdout: scanOutput })
})
)
const first = listWorktrees('/repo')
const second = listWorktrees('/repo')
resolveScan()
const [a, b] = await Promise.all([first, second])
expect(a).toEqual(b)
expect(a[0]?.path).toBe('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('runs a fresh scan once the shared one has settled', async () => {
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
await listWorktrees('/repo')
await listWorktrees('/repo')
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not share scans across different repos', async () => {
gitExecFileAsyncMock.mockImplementation((_args: string[], options: { cwd: string }) =>
Promise.resolve({
stdout: `worktree ${options.cwd}\nHEAD abc123\nbranch refs/heads/main\n`
})
)
await Promise.all([listWorktrees('/repo-one'), listWorktrees('/repo-two')])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not share scans for callers with an AbortSignal', async () => {
const scanOutput = 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n'
gitExecFileAsyncMock.mockResolvedValue({ stdout: scanOutput })
const controller = new AbortController()
// Why: an aborted shared scan would reject for every caller, so signal
// callers must keep a private scan.
await Promise.all([
listWorktrees('/repo'),
listWorktrees('/repo', { signal: controller.signal })
])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
})
describe('parseWorktreeList', () => {
it('parses regular and bare worktree blocks from porcelain output', () => {
const output = `
@@ -237,6 +306,12 @@ bare
})
describe('listWorktreeGraph', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockClear()
})
it('returns the worktree graph without sparse-checkout annotation probes', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: `worktree /repo
@@ -272,6 +347,98 @@ branch refs/heads/feature/test
})
})
it('falls back when older Git rejects worktree list -z with usage exit code', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(
Object.assign(new Error('git usage error'), {
code: 129,
stderr: 'usage: git worktree list [<options>]\n'
})
)
.mockResolvedValueOnce({
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
worktree /repo-main-2
HEAD def456
branch refs/heads/main-2
`
})
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
{
path: '/repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
},
{
path: '/repo-main-2',
head: 'def456',
branch: 'refs/heads/main-2',
isBare: false,
isMainWorktree: false
}
])
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
])
})
it('falls back on a localized (non-English) older-Git usage error via exit code 129', async () => {
// Git under a non-English locale translates the usage text, so stderr
// matching alone misses it; the 129 exit code must still trigger fallback.
gitExecFileAsyncMock
.mockRejectedValueOnce(
Object.assign(new Error('git worktree list --porcelain -z'), {
code: 129,
stderr: 'Fehler: Unbekannter Schalter »z«\nAufruf: git worktree list [<Optionen>]\n'
})
)
.mockResolvedValueOnce({
stdout: `worktree /repo
HEAD abc123
branch refs/heads/main
`
})
await expect(listWorktreeGraph('/repo')).resolves.toEqual([
{
path: '/repo',
head: 'abc123',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true
}
])
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }],
[['worktree', 'list', '--porcelain'], { cwd: '/repo' }]
])
})
it('does not retry without -z for non-usage Git failures', async () => {
// A fatal error (exit 128) is not an unsupported-flag signal, so the -z
// command must not be silently re-run without it.
gitExecFileAsyncMock.mockRejectedValueOnce(
Object.assign(new Error('git worktree list --porcelain -z'), {
code: 128,
stderr: 'fatal: unable to read tree\n'
})
)
await expect(listWorktreeGraph('/repo')).resolves.toEqual([])
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[['worktree', 'list', '--porcelain', '-z'], { cwd: '/repo' }]
])
})
it('returns an empty graph for paths Git reports as non-repositories', async () => {
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('fatal: not a git repository'))
+92 -6
View File
@@ -104,9 +104,13 @@ function isNotGitRepositoryError(error: unknown): boolean {
}
function isUnsupportedWorktreeListZError(error: unknown): boolean {
return /(?:unknown|invalid) (?:switch|option).*`?-z'?|(?:unknown|invalid) (?:switch|option).*`?z'?/i.test(
getErrorText(error)
)
// `-z` is this command's only flag older Git (<2.36) lacks, so its usage
// exit 129 signals the rejection in any locale; stderr match is a fallback.
if (getErrorCode(error) === '129') {
return true
}
return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error))
}
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
@@ -605,10 +609,51 @@ export async function listWorktreeGraph(
}
}
// Why: a cold start lists every repo's worktrees from several independent
// paths (renderer worktrees fetch, lineage resolution, boot pty-registry
// hydration), and each `git worktree list` is a full process spawn —
// expensive on Windows (issue #7225). Sharing the in-flight promise collapses
// concurrent duplicate scans; nothing is served after a scan settles.
const inFlightWorktreeScans = new Map<string, Promise<GitWorktreeInfo[]>>()
// Why: a caller listing AFTER a mutation completed must observe it, so it may
// not join a scan that started before the mutation. Bumping the generation on
// mutation completion retires older in-flight scans from sharing (they still
// settle for their original callers).
const worktreeScanGenerations = new Map<string, number>()
function bumpWorktreeScanGeneration(repoPath: string): void {
worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1)
}
/**
* List all worktrees for a git repo at the given path.
* List all worktrees for a git repo at the given path. Concurrent calls for
* the same repo share one scan (unless the caller passes an AbortSignal,
* which must only cancel its own scan).
*/
export async function listWorktrees(
export function listWorktrees(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
if (options.signal) {
return listWorktreesUnshared(repoPath, options)
}
const generation = worktreeScanGenerations.get(repoPath) ?? 0
const key = `${repoPath}\0${options.wslDistro ?? ''}\0${generation}`
const inFlight = inFlightWorktreeScans.get(key)
if (inFlight) {
return inFlight
}
const scan = listWorktreesUnshared(repoPath, options).finally(() => {
if (inFlightWorktreeScans.get(key) === scan) {
inFlightWorktreeScans.delete(key)
}
})
inFlightWorktreeScans.set(key, scan)
return scan
}
async function listWorktreesUnshared(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
@@ -763,6 +808,30 @@ export async function addWorktree(
refreshLocalBaseRef = false,
noCheckout = false,
options: AddWorktreeOptions = {}
): Promise<AddWorktreeResult> {
try {
return await performAddWorktree(
repoPath,
worktreePath,
branch,
baseBranch,
refreshLocalBaseRef,
noCheckout,
options
)
} finally {
bumpWorktreeScanGeneration(repoPath)
}
}
async function performAddWorktree(
repoPath: string,
worktreePath: string,
branch: string,
baseBranch?: string,
refreshLocalBaseRef = false,
noCheckout = false,
options: AddWorktreeOptions = {}
): Promise<AddWorktreeResult> {
let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined
let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined
@@ -953,7 +1022,11 @@ export async function moveWorktree(
oldPath: string,
newPath: string
): Promise<void> {
await gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
try {
await gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
} finally {
bumpWorktreeScanGeneration(repoPath)
}
}
/**
@@ -968,6 +1041,19 @@ export async function removeWorktree(
// and must be removed outright. User-initiated deletes leave it false so unmerged
// commits are preserved.
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
try {
return await performRemoveWorktree(repoPath, worktreePath, force, options)
} finally {
bumpWorktreeScanGeneration(repoPath)
}
}
async function performRemoveWorktree(
repoPath: string,
worktreePath: string,
force = false,
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
const removedWorktree =
options.knownRemovedWorktree ??
+8 -8
View File
@@ -245,7 +245,7 @@ export async function getProjectSlug(
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<ProjectRef | null> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
return getProjectRef(
repoPath,
knownHosts,
@@ -265,7 +265,7 @@ export async function getMergeRequest(
connectionId?: string | null,
options: HostedReviewExecutionOptions = {}
): Promise<MRInfo | null> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
const localGitOptions = localGitArgs[0] ?? {}
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs)
@@ -316,7 +316,7 @@ export async function getMergeRequestForBranch(
if (!branchName && linkedMRIid == null) {
return null
}
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const localGitArgs = hostedReviewLocalGitOptionArgs(options)
const localGitOptions = localGitArgs[0] ?? {}
const projectRef = await getProjectRef(repoPath, knownHosts, connectionId, ...localGitArgs)
@@ -400,7 +400,7 @@ export async function listMergeRequests(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ListMergeRequestsResult> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
// Why: MRs sit on `origin` in the fork model (the user's fork is where
// they push branches and submit MRs). Mirror github's `getOwnerRepo`
// call site by going through the upstream/origin preference resolver
@@ -429,7 +429,7 @@ export async function listMergeRequests(
}
}
// Why: fallback — let glab infer project from cwd, same as listIssues.
// Used when the repo's remote host is not in getGlabKnownHosts()
// Used when the repo's remote host is not in getGlabKnownHosts(connectionId)
// (e.g. a fresh self-hosted instance), but glab itself can still
// resolve it from the local git config.
const stateFlag = mrListStateFlags(state)
@@ -598,7 +598,7 @@ export async function listWorkItems(
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabPagedResult<GitLabWorkItem>> {
const issueState = mrStateToIssueState(state)
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
@@ -734,7 +734,7 @@ export async function listTodos(
): Promise<GitLabTodo[]> {
const projectRef = await getProjectRef(
repoPath,
await getGlabKnownHosts(),
await getGlabKnownHosts(connectionId),
connectionId,
localGitOptions
)
@@ -813,7 +813,7 @@ async function withProjectRef<T>(
await resolveIssueSource(
repoPath,
preference,
await getGlabKnownHosts(),
await getGlabKnownHosts(connectionId),
connectionId,
localGitOptions
)
@@ -20,7 +20,17 @@ export type LocalGitExecOptions = {
const PROJECT_REF_CACHE_MAX_ENTRIES = 512
const projectRefCache = new Map<string, ProjectRef | null>()
let knownHostsCache: readonly string[] | null = null
// Why: known hosts are cached PER connection. A repo on an SSH connection
// authenticates against a different glab context than the local one, so a
// process-global cache would leak one connection's hosts into another (and
// poison a connection that probes before its tunnel is ready). The local
// context uses the `'local'` key.
const LOCAL_CONNECTION_KEY = 'local'
const knownHostsCacheByConnection = new Map<string, readonly string[]>()
function connectionCacheKey(connectionId?: string | null): string {
return connectionId ?? LOCAL_CONNECTION_KEY
}
/** @internal - exposed for tests only */
export function _resetProjectRefCache(): void {
@@ -35,7 +45,7 @@ export function _getProjectRefCacheSize(): number {
/** @internal - exposed for tests only */
export function _resetKnownHostsCache(): void {
knownHostsCache = null
knownHostsCacheByConnection.clear()
}
function rememberProjectRefCacheEntry(cacheKey: string, value: ProjectRef | null): void {
@@ -108,7 +118,7 @@ async function resolveProjectRefForRemote(
localGitOptions
))
) {
rememberGlabKnownHost(remoteCandidate.host)
rememberGlabKnownHost(remoteCandidate.host, connectionId)
rememberProjectRefCacheEntry(cacheKey, remoteCandidate)
return remoteCandidate
}
@@ -220,12 +230,14 @@ export function glabHostnameArgs(
return connectionId && projectRef?.host ? ['--hostname', projectRef.host] : []
}
function rememberGlabKnownHost(host: string): void {
function rememberGlabKnownHost(host: string, connectionId?: string | null): void {
const normalizedHost = normalizeGitLabHost(host)
if (!knownHostsCache || knownHostsCache.map(normalizeGitLabHost).includes(normalizedHost)) {
const key = connectionCacheKey(connectionId)
const cached = knownHostsCacheByConnection.get(key)
if (!cached || cached.map(normalizeGitLabHost).includes(normalizedHost)) {
return
}
knownHostsCache = [...knownHostsCache, normalizedHost]
knownHostsCacheByConnection.set(key, [...cached, normalizedHost])
}
async function isGlabConfiguredForRemoteHost(
@@ -251,30 +263,47 @@ async function isGlabConfiguredForRemoteHost(
}
}
export async function getGlabKnownHosts(): Promise<readonly string[]> {
if (knownHostsCache) {
return knownHostsCache
export async function getGlabKnownHosts(connectionId?: string | null): Promise<readonly string[]> {
const key = connectionCacheKey(connectionId)
const cached = knownHostsCacheByConnection.get(key)
if (cached) {
return cached
}
try {
// Why: `glab auth status` is host-scoped, not cwd-scoped — glab reads its
// own config to list authenticated hosts. The connectionId is threaded so
// the RESULT is cached per connection (a connected repo can have a
// different set of authenticated self-hosted hosts than the local one),
// mirroring how project-ref resolution caches per connection.
const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'])
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
knownHostsCache = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
return knownHostsCache
const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...hosts]))
knownHostsCacheByConnection.set(key, merged)
return merged
} catch {
knownHostsCache = [...DEFAULT_GITLAB_HOSTS]
return knownHostsCache
// Auth check failed (glab not installed, no auth, tunnel not ready,
// etc.) — fall back to the canonical default for THIS call, but do NOT
// cache the fallback. A later probe (e.g. after the SSH tunnel comes
// up) must be able to discover the real self-hosted host.
return [...DEFAULT_GITLAB_HOSTS]
}
}
export function parseGlabAuthStatusHosts(output: string): string[] {
const hosts = new Set<string>()
for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+)/gi)) {
// Why: self-hosted GitLab can run on a non-default port (e.g.
// `gitlab.example.com:8443`); capture the optional `:port` so two services
// on the same hostname but different ports stay distinct downstream.
for (const m of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) {
hosts.add(m[1].toLowerCase())
}
for (const line of output.split('\n')) {
const bareLine = line.trim()
const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine
if (line === bareLine && /^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?$/.test(hostLine)) {
if (
line === bareLine &&
/^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$/.test(hostLine)
) {
hosts.add(hostLine.toLowerCase())
}
}
+72
View File
@@ -344,6 +344,30 @@ Self-hosted-git
it('returns empty list for output with no hosts', () => {
expect(parseGlabAuthStatusHosts('Not logged in.')).toEqual([])
})
it('captures a non-default port on "Logged in to" lines', () => {
const out = '✓ Logged in to gitlab.example.com:8080 as user (token)'
expect(parseGlabAuthStatusHosts(out)).toEqual(['gitlab.example.com:8080'])
})
it('captures a non-default port on header-style lines', () => {
const out = `
gitlab.example.com:8080:
✓ Logged in as user
`
expect(parseGlabAuthStatusHosts(out)).toContain('gitlab.example.com:8080')
})
it('keeps two services on the same host distinct by port', () => {
const out = `
✓ Logged in to gitlab.example.com:8443 as user (token)
✓ Logged in to gitlab.example.com:3030 as user (token)
`
expect(parseGlabAuthStatusHosts(out).sort()).toEqual([
'gitlab.example.com:3030',
'gitlab.example.com:8443'
])
})
})
describe('parseGlabApiResponse', () => {
@@ -428,4 +452,52 @@ describe('getGlabKnownHosts', () => {
await getGlabKnownHosts()
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('recognizes a self-hosted host on a non-default port', async () => {
glabExecFileAsyncMock.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com', 'gitlab.example.com:8080'])
})
it('caches per connection — the local probe does not satisfy a connection probe', async () => {
glabExecFileAsyncMock
.mockResolvedValueOnce({ stdout: '✓ Logged in to gitlab.com as user\n', stderr: '' })
.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
await expect(getGlabKnownHosts()).resolves.toEqual(['gitlab.com'])
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
// A second probe for the same connection is served from cache.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('does not permanently cache the failure fallback — a later probe can re-discover hosts', async () => {
glabExecFileAsyncMock
.mockRejectedValueOnce(new Error('ssh tunnel not ready'))
.mockResolvedValueOnce({
stdout: '✓ Logged in to gitlab.example.com:8080 as user\n',
stderr: ''
})
// First probe fails → canonical default, NOT cached.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual(['gitlab.com'])
// Re-probe (e.g. after tunnel comes up) discovers the real host.
await expect(getGlabKnownHosts('conn-1')).resolves.toEqual([
'gitlab.com',
'gitlab.example.com:8080'
])
expect(glabExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
})
+25 -21
View File
@@ -187,31 +187,35 @@ describe('gitlab issue operations', () => {
expect(result.error?.type).toBe('permission_denied')
})
it('falls back to glab issue list with updated ordering for unresolved self-hosted repos', async () => {
it('returns an isolated not_found error (never a cwd-inferred glab call) when the project is unresolved', async () => {
// Why: a cwd-inferred `glab issue list` would hit `git: exit status 128`
// on an SSH connection and, in an "All projects" aggregate, sink the
// whole panel. The unresolvable project must isolate to a structured
// error instead, and must not spawn any glab subprocess.
getIssueProjectRefMock.mockResolvedValueOnce(null)
const result = await listIssues('/repo-root', 5, undefined, 'opened', '@me')
expect(result.items).toEqual([])
expect(result.error?.type).toBe('not_found')
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
expect(acquireMock).not.toHaveBeenCalled()
})
it('returns null for getIssue (and spawns no glab call) when the project is unresolved', async () => {
getIssueProjectRefMock.mockResolvedValueOnce(null)
await expect(getIssue('/repo-root', 7)).resolves.toBeNull()
expect(glabExecFileAsyncMock).not.toHaveBeenCalled()
})
it('threads connectionId into getGlabKnownHosts for listIssues', async () => {
getIssueProjectRefMock.mockResolvedValueOnce({ host: 'gitlab.com', path: 'stablyai/orca' })
glabExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await expect(listIssues('/repo-root', 5, undefined, 'opened', '@me')).resolves.toEqual({
items: []
})
await listIssues('/repo-root', 5, undefined, 'opened', undefined, 'conn-7')
expect(glabExecFileAsyncMock).toHaveBeenCalledWith(
[
'issue',
'list',
'--output',
'json',
'--per-page',
'5',
'--order',
'updated_at',
'--sort',
'desc',
'--assignee',
'@me'
],
{ cwd: '/repo-root' }
)
expect(getGlabKnownHostsMock).toHaveBeenCalledWith('conn-7')
})
it('creates an issue and returns its iid + web_url', async () => {
+45 -57
View File
@@ -44,25 +44,24 @@ export async function getIssue(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabIssueInfo | null> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const projectRef = await getIssueProjectRef(repoPath, knownHosts, connectionId, localGitOptions)
// Why: don't fall back to a cwd-inferred `glab issue view` when the project
// can't be resolved — on an SSH connection cwd is not the repo dir, so glab
// hits a non-repo dir and fails with `git: exit status 128`. Return null
// (the caller already treats a missing project as "no issue") instead of
// spawning a doomed cwd-dependent call.
if (!projectRef) {
return null
}
await acquire()
try {
if (projectRef) {
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout)
return mapGitLabIssueInfo(data)
}
// Fallback for non-GitLab remotes — let glab infer the project from cwd.
const { stdout } = await glabExecFileAsync(
['issue', 'view', String(issueNumber), '--output', 'json'],
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues/${issueNumber}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout)
@@ -93,7 +92,7 @@ export async function listIssues(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<IssueListResult> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
@@ -101,49 +100,38 @@ export async function listIssues(
connectionId,
localGitOptions
)
await acquire()
try {
if (projectRef) {
const stateParam = state === 'all' ? '' : `&state=${state}`
const scopeParam = assignee === '@me' ? '&scope=assigned_to_me' : ''
const { stdout } = await glabExecFileAsync(
[
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as Record<string, unknown>[]
// Why: GitLab's project issues endpoint returns true issues only
// (MRs are a separate endpoint), so no equivalent of GitHub's
// pull_request filter is needed here.
return {
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
// Why: when the project can't be resolved we must NOT fall back to an
// unscoped `glab issue list` that infers the project from cwd. For a repo
// on an SSH connection there is no local cwd matching the repo, so glab
// runs git resolution in a non-repo dir and fails with `git: exit status
// 128`. In an "All projects" aggregate one such failure must not sink the
// whole panel — return a structured, isolated result so the resolvable
// projects still load.
if (!projectRef) {
return {
items: [],
error: {
type: 'not_found',
message: 'Could not resolve a GitLab project for this repository.'
}
}
// Fallback — let glab infer project from cwd. glab issue list defaults
// to opened; only pass --closed / --all when explicitly requested.
const stateFlag = state === 'closed' ? ['--closed'] : state === 'all' ? ['--all'] : []
const assigneeFlag = assignee ? ['--assignee', assignee] : []
}
await acquire()
try {
const stateParam = state === 'all' ? '' : `&state=${state}`
const scopeParam = assignee === '@me' ? '&scope=assigned_to_me' : ''
const { stdout } = await glabExecFileAsync(
[
'issue',
'list',
'--output',
'json',
'--per-page',
String(limit),
'--order',
'updated_at',
'--sort',
'desc',
...stateFlag,
...assigneeFlag
'api',
...glabHostnameArgs(projectRef, connectionId),
`projects/${encodedProject(projectRef.path)}/issues?per_page=${limit}&order_by=updated_at&sort=desc${stateParam}${scopeParam}`
],
glabRepoExecOptions(repoPath, connectionId, localGitOptions)
)
const data = JSON.parse(stdout) as unknown[]
const data = JSON.parse(stdout) as Record<string, unknown>[]
// Why: GitLab's project issues endpoint returns true issues only
// (MRs are a separate endpoint), so no equivalent of GitHub's
// pull_request filter is needed here.
return {
items: data.map((d) => mapGitLabIssueInfo(d as Parameters<typeof mapGitLabIssueInfo>[0]))
}
@@ -174,7 +162,7 @@ export async function createIssue(
if (!trimmedTitle) {
return { ok: false, error: 'Title is required' }
}
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
@@ -244,7 +232,7 @@ export async function updateIssue(
await resolveIssueSource(
repoPath,
preference,
await getGlabKnownHosts(),
await getGlabKnownHosts(connectionId),
connectionId,
localGitOptions
)
@@ -381,7 +369,7 @@ export async function addIssueComment(
await resolveIssueSource(
repoPath,
preference,
await getGlabKnownHosts(),
await getGlabKnownHosts(connectionId),
connectionId,
localGitOptions
)
@@ -439,7 +427,7 @@ export async function listLabels(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<string[]> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
@@ -480,7 +468,7 @@ export async function listAssignableUsers(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitLabAssignableUser[]> {
const knownHosts = await getGlabKnownHosts()
const knownHosts = await getGlabKnownHosts(connectionId)
const { source: projectRef } = await resolveIssueSource(
repoPath,
preference,
+48 -1
View File
@@ -38,21 +38,68 @@ describe('gitlab project ref parsing', () => {
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
})
it('parses GitLab remotes with non-standard ports without treating the port as a path segment', () => {
it('drops the SSH transport port from the GitLab host identity', () => {
// Why: for ssh remotes the port is a transport port (e.g. :2222), not the
// web/API endpoint, so it must not become part of the recognized host.
expect(
parseGitLabProjectRef('ssh://git@gitlab.example.com:2222/team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
})
it('keeps the HTTP(S) port as part of the self-hosted GitLab host identity', () => {
// Why: a self-hosted GitLab served on a non-default web port (e.g. :8443)
// is identified by host:port end-to-end so `glab --hostname` targets it.
expect(
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
'gitlab.com',
'gitlab.example.com:8443'
])
).toEqual({ host: 'gitlab.example.com:8443', path: 'team/api' })
})
it('matches a port-bearing http remote against a port-less legacy known host', () => {
// Why: a known host recorded without a port (legacy/bare entry) still
// recognizes a remote on any port of the same hostname.
expect(
parseGitLabProjectRef('https://gitlab.example.com:8443/team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com:8443', path: 'team/api' })
})
it('distinguishes two services on the same host by port — only the GitLab one matches', () => {
// Why: a GitLab on :8443 and a Gitea on :3030 share a hostname but are
// different services. With only the GitLab port in known hosts, the Gitea
// remote must NOT be classified as GitLab.
const knownHosts = ['gitlab.com', 'gitea.example.com:8443']
expect(parseGitLabProjectRef('http://gitea.example.com:8443/team/api.git', knownHosts)).toEqual(
{ host: 'gitea.example.com:8443', path: 'team/api' }
)
expect(
parseGitLabProjectRef('http://gitea.example.com:3030/team/api.git', knownHosts)
).toBeNull()
})
it('matches an SCP-like self-hosted remote against a port-less known host', () => {
expect(
parseGitLabProjectRef('git@gitlab.example.com:team/api.git', [
'gitlab.com',
'gitlab.example.com'
])
).toEqual({ host: 'gitlab.example.com', path: 'team/api' })
})
it('keeps gitlab.com (no port) recognized as a default host', () => {
expect(parseGitLabProjectRef('https://gitlab.com/acme/widgets.git')).toEqual({
host: 'gitlab.com',
path: 'acme/widgets'
})
})
it('rejects single-segment paths (host root or user-only)', () => {
expect(parseGitLabProjectRef('git@gitlab.com:foo.git')).toBeNull()
expect(parseGitLabProjectRef('https://gitlab.com/foo.git')).toBeNull()
+47 -3
View File
@@ -12,10 +12,33 @@ export function normalizeGitLabHost(value: string): string {
return value.trim().toLowerCase()
}
// Why: host recognition is port-aware so two services on the same hostname
// but different ports (e.g. a GitLab on :8080 and a Gitea on :3030) are not
// conflated. The hostname (port-less) part is kept for legacy known-host
// entries that were recorded without a port.
function hostnameOf(host: string): string {
// `host` may be `name` or `name:port`. Strip a trailing `:digits` port.
return host.replace(/:\d+$/, '')
}
function stripGitSuffix(path: string): string {
return path.replace(/\/+$/, '').replace(/\.git$/i, '')
}
// Why: the GitLab host identity is the web/API endpoint, which is what `glab
// --hostname` and the known-hosts list speak in terms of. For http(s)
// remotes the URL port IS that endpoint port (e.g. self-hosted on :8080),
// so it must be kept. For ssh/git remotes the port is a transport port
// (e.g. ssh on :2222) that does not identify the GitLab instance, so it is
// dropped and only the hostname is used.
function hostIdentityFromUrl(url: URL): string {
const protocol = url.protocol.toLowerCase()
if (protocol === 'http:' || protocol === 'https:') {
return url.host
}
return url.hostname
}
function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef | null {
const normalizedHost = normalizeGitLabHost(host)
const normalizedPath = stripGitSuffix(path.replace(/^\/+/, '')).trim()
@@ -27,6 +50,27 @@ function makeProjectRefForTrustedHost(host: string, path: string): ProjectRef |
return { host: normalizedHost, path: normalizedPath }
}
/**
* Does `urlHost` (which may include a `:port`) match a known-host entry?
* - An exact match (including any port) always counts.
* - A known entry WITHOUT a port also matches a URL host on the same
* hostname regardless of the URL's port — this preserves recognition for
* legacy `gitlab.com` / bare-hostname known entries.
* - A known entry WITH a port only matches a URL host with the exact same
* port, so `gitlab.example.com:8443` does not accept a
* `gitea.example.com:3000` (or same-host different-port) remote.
*/
function knownHostMatches(urlHost: string, knownHost: string): boolean {
if (urlHost === knownHost) {
return true
}
if (hostnameOf(knownHost) === knownHost) {
// Known entry has no port — match on hostname alone.
return hostnameOf(urlHost) === knownHost
}
return false
}
function makeProjectRef(
host: string,
path: string,
@@ -34,7 +78,7 @@ function makeProjectRef(
): ProjectRef | null {
const normalizedHost = normalizeGitLabHost(host)
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
if (!normalizedKnownHosts.includes(normalizedHost)) {
if (!normalizedKnownHosts.some((knownHost) => knownHostMatches(normalizedHost, knownHost))) {
return null
}
return makeProjectRefForTrustedHost(normalizedHost, path)
@@ -54,7 +98,7 @@ export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef |
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
return makeProjectRefForTrustedHost(url.hostname, url.pathname)
return makeProjectRefForTrustedHost(hostIdentityFromUrl(url), url.pathname)
} catch {
return null
}
@@ -77,7 +121,7 @@ export function parseGitLabProjectRef(
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
return makeProjectRef(url.hostname, url.pathname, knownHosts)
return makeProjectRef(hostIdentityFromUrl(url), url.pathname, knownHosts)
} catch {
return null
}
+1 -1
View File
@@ -361,7 +361,7 @@ export async function getWorkItemDetails(
await resolveIssueSource(
repoPath,
preference,
await getGlabKnownHosts(),
await getGlabKnownHosts(connectionId),
connectionId,
localGitOptions
)
+5
View File
@@ -76,7 +76,12 @@ describe('GrokHookService', () => {
if (process.platform === 'win32') {
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
} else {
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
// on the curl command line (EDR oversized-command-line false positive).
expect(script).toContain('payload=$(cat)')
expect(script).toContain('printf \'%s\' "$payload" | curl')
expect(script).toContain('--data-urlencode "payload@-"')
expect(script).not.toContain('--data-urlencode "payload=${payload}"')
}
})
+5 -2
View File
@@ -90,7 +90,10 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' exit 0',
'fi',
// Timeout caps best-effort hook posts if the local listener stalls.
'curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/grok" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
@@ -100,7 +103,7 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string {
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${payload}" >/dev/null 2>&1 || true',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
+108 -28
View File
@@ -91,13 +91,20 @@ import {
shouldBypassSingleInstanceLock
} from './startup/single-instance-lock'
import { startEventLoopStallProbe } from './startup/event-loop-stall-probe'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup/startup-diagnostics'
import {
isStartupDiagnosticsEnabled,
logStartupDiagnostic,
logStartupMilestone
} from './startup/startup-diagnostics'
import { ensureWindowsUserDataAclGrant } from './startup/windows-user-data-acl'
import { shouldQuitWhenAllWindowsClosed } from './startup/window-all-closed-quit-policy'
import { RateLimitService } from './rate-limits/service'
import { getInitialClaudeRateLimitTarget } from './rate-limits/claude-rate-limit-target'
import { getInitialCodexRateLimitTarget } from './rate-limits/codex-rate-limit-target'
import { attachMainWindowServices } from './window/attach-main-window-services'
import {
attachMainWindowServices,
ensureAutoUpdaterConfigured
} from './window/attach-main-window-services'
import { createMainWindow, loadMainWindow } from './window/createMainWindow'
import { createSystemTray, destroySystemTray } from './tray/system-tray'
import { focusExistingMainWindow } from './window/focus-existing-window'
@@ -171,6 +178,8 @@ import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-fac
import { KeybindingService } from './keybindings/keybinding-service'
import { applyElectronProxySettings } from './network/proxy-settings'
import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation'
import { CliInstaller } from './cli/cli-installer'
import { installLinuxBareOrcaDispatcher } from './cli/linux-bare-orca-dispatcher'
let mainWindow: BrowserWindow | null = null
/** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the
@@ -397,6 +406,11 @@ if (app.isPackaged && process.platform !== 'win32') {
}
configureDevUserDataPath(is.dev)
configureOrcaUserDataPathEnv()
// Why: just past createMainWindow's win32 10s ready-to-show reveal fallback,
// so a window revealed on that path still gets its tray icon.
const TRAY_CREATE_FALLBACK_MS = 12_000
const startupDiagnosticsEnabled = isStartupDiagnosticsEnabled()
if (startupDiagnosticsEnabled) {
logStartupDiagnostic('before-single-instance-lock', {
@@ -410,14 +424,6 @@ if (startupDiagnosticsEnabled) {
startEventLoopStallProbe()
}
// Why: startup benchmarking needs in-process timestamps — harness-side stderr
// arrival times include pipe buffering jitter. `t` is ms since process start.
function logStartupMilestone(event: string, details: Record<string, unknown> = {}): void {
if (startupDiagnosticsEnabled) {
logStartupDiagnostic(event, { t: Math.round(performance.now()), ...details })
}
}
function focusExistingWindow(): void {
focusExistingMainWindow({
app,
@@ -781,9 +787,45 @@ function openMainWindow(): BrowserWindow {
})
recordCrashBreadcrumb('main_window_created')
logStartupMilestone('window-created')
// Why: new Tray() is a synchronous Shell_NotifyIcon call that can block for
// seconds while explorer.exe's notification area is busy (part of issue
// #7225's pre-paint stall), so create it after first paint. The timer
// fallback covers windows revealed without ready-to-show ever firing
// (createMainWindow's win32 10s reveal fallback) — those can still be
// hidden to the tray on close, so the icon must exist by then.
let trayCreated = false
const createSystemTrayDeferred = (): void => {
if (trayCreated || window.isDestroyed() || isQuitting || !store) {
return
}
trayCreated = true
// Why: Windows-only system tray. createSystemTray is idempotent and a
// no-op off win32, so calling it on each window open keeps exactly one
// live icon.
createSystemTray({
appIcon: store.getSettings().appIcon,
onOpen: showMainWindowFromTray,
onQuit: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
// Why: a real quit can still surface renderer save/discard prompts;
// the window must be visible if a hidden-to-tray session vetoes
// shutdown.
showMainWindowFromTray()
}
// Why: set the quit latch before app.quit() so the window 'close'
// handler proceeds to teardown instead of re-hiding to the tray.
isQuitting = true
app.quit()
}
})
logStartupMilestone('tray-created')
}
window.once('ready-to-show', () => {
logStartupMilestone('ready-to-show')
setImmediate(createSystemTrayDeferred)
})
const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS)
trayCreateFallback.unref?.()
// Why: telemetry-plan.md§First-launch experience anchors default-on
// `app_opened` to the first main-window load. Existing users in the
@@ -879,23 +921,6 @@ function openMainWindow(): BrowserWindow {
stopAllSyntheticTitleSpinners()
})
mainWindow = window
// Why: Windows-only system tray. createSystemTray is idempotent and a no-op
// off win32, so calling it on each window open keeps exactly one live icon.
createSystemTray({
appIcon: store.getSettings().appIcon,
onOpen: showMainWindowFromTray,
onQuit: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
// Why: a real quit can still surface renderer save/discard prompts; the
// window must be visible if a hidden-to-tray session vetoes shutdown.
showMainWindowFromTray()
}
// Why: set the quit latch before app.quit() so the window 'close' handler
// proceeds to teardown instead of re-hiding the window to the tray.
isQuitting = true
app.quit()
}
})
window.on('show', resumeSyntheticTitleSpinnerTimer)
window.on('restore', resumeSyntheticTitleSpinnerTimer)
window.on('hide', stopSyntheticTitleSpinnerTimer)
@@ -1845,7 +1870,12 @@ app.whenReady().then(async () => {
logStartupMilestone('i18n-ready')
registerAppMenu({
onCheckForUpdates: (options) => checkForUpdatesFromMenu(options),
onCheckForUpdates: (options) => {
// Why: the menu is clickable before first paint; a manual check must
// run against a configured updater (see attach-main-window-services).
ensureAutoUpdaterConfigured()
return checkForUpdatesFromMenu(options)
},
onBeforeReload: ({ ignoreCache, webContentsId }) => {
if (mainWindow?.webContents.id === webContentsId) {
markExpectedRendererReload(webContentsId)
@@ -1989,6 +2019,56 @@ app.whenReady().then(async () => {
throw error
})
installServeSignalHandlers()
// Why: the orca CLI command is normally installed by the renderer onboarding /
// Settings "Install CLI" flow via the cli:install IPC. Headless serve has no
// renderer, so the command is never created and an in-terminal `orca …` fails
// with command-not-found. Run the idempotent installer here for the platforms
// where it puts a resolvable command on the managed-terminal PATH: macOS (bare
// `orca` in /usr/local/bin or ~/.local/bin) and Linux (`orca-ide`; bare `orca`
// is added by the dispatcher below). Windows is excluded — there install() would
// only mutate the persistent user-registry PATH without helping the current
// serve's child terminals. Best-effort: a failure must not block serve start.
if (process.platform === 'darwin' || process.platform === 'linux') {
try {
// Why: serve is headless — never let a missing-write-permission fallback
// pop an osascript admin prompt (it would hang serve on a GUI host). Skip
// instead; the user-writable ~/.local/bin path needs no elevation anyway.
const cliStatus = await new CliInstaller({
privilegedRunner: async () => {
throw new Error('serve CLI auto-install must not request administrator privileges')
}
}).install()
console.log(
`[serve] orca CLI install: ${cliStatus.state}${cliStatus.commandPath ? ` (${cliStatus.commandPath})` : ''}`
)
} catch (error) {
console.warn(
'[serve] orca CLI install skipped:',
error instanceof Error ? error.message : String(error)
)
}
}
// Why: on Linux the CLI installs as `orca-ide`, NOT bare `orca` (above), but the
// Claude Team launcher typed into the initial managed terminal invokes bare `orca`.
// Drop a bare-`orca` dispatcher on ~/.local/bin (ahead of /usr/bin on the managed
// terminal PATH) so `orca claude-teams` resolves. Best-effort: a failure must not
// block serve startup. See installLinuxBareOrcaDispatcher for the full rationale.
if (process.platform === 'linux' && app.isPackaged && process.resourcesPath) {
try {
const dispatcher = await installLinuxBareOrcaDispatcher({
resourcesPath: process.resourcesPath
})
console.log(
`[serve] bare orca dispatcher ${dispatcher.state}: ${dispatcher.dispatcherPath}` +
`${dispatcher.target ? ` -> ${dispatcher.target}` : ''}`
)
} catch (error) {
console.warn(
'[serve] bare orca dispatcher install skipped:',
error instanceof Error ? error.message : String(error)
)
}
}
await printServeReady(serveOptions)
return
}
-1
View File
@@ -71,7 +71,6 @@ vi.mock('../git/runner', () => ({
vi.mock('../git/repo', () => ({
isGitRepo: vi.fn().mockReturnValue(true),
getGitUsername: vi.fn().mockReturnValue(''),
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
searchBaseRefs: vi.fn().mockResolvedValue([])
-1
View File
@@ -22,7 +22,6 @@ vi.mock('../git/runner', () => ({
vi.mock('../git/repo', () => ({
isGitRepo: vi.fn(),
getGitUsername: vi.fn(),
getRepoName: vi.fn(),
getBaseRefDefault: vi.fn(),
searchBaseRefs: vi.fn()
-1
View File
@@ -94,7 +94,6 @@ vi.mock('../git/repo', async () => {
// Stub only the functions that spawn git / touch the filesystem.
isGitRepo: vi.fn().mockReturnValue(true),
getGitRepoRoot: vi.fn((path: string) => path),
getGitUsername: vi.fn().mockReturnValue(''),
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
getRemoteCount: vi.fn().mockResolvedValue(1),
@@ -35,7 +35,6 @@ vi.mock('electron', () => ({
vi.mock('../git/repo', () => ({
isGitRepo: vi.fn().mockReturnValue(true),
getGitUsername: vi.fn().mockReturnValue(''),
getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()),
getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'),
searchBaseRefs: vi.fn().mockResolvedValue([]),
+9 -3
View File
@@ -61,7 +61,6 @@ import { createNestedRepoImportTargetResolver } from '../project-groups/nested-r
import {
isGitRepo,
getGitRepoRoot,
getGitUsername,
getRepoName,
getBaseRefDefault,
getRemoteCount,
@@ -76,7 +75,8 @@ import {
} from '../git/repo'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getSshGitUsername } from '../git/git-username'
import { getSshGitUsername, resolveLocalGitUsername } from '../git/git-username'
import { enrichRepoGitUsernames } from '../repo-git-username-enrichment'
import { getActiveMultiplexer } from './ssh'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
import { track } from '../telemetry/client'
@@ -1163,6 +1163,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
enrichMissingRepoGitRemoteIdentities(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
// Why: username resolution spawns git/gh and must stay off this handler's
// synchronous path (issue #7225); the background pass notifies the
// renderer to re-list once values land.
enrichRepoGitUsernames(store, {
onChanged: () => notifyReposChanged(mainWindow)
})
return store.getRepos()
})
@@ -2400,7 +2406,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
return getSshGitUsername(provider, repo.path)
}
return getGitUsername(repo.path)
return resolveLocalGitUsername(repo.path)
})
ipcMain.handle(

Some files were not shown because too many files have changed in this diff Show More