Merge remote-tracking branch 'origin/main' into brennanb2025/win-codex-merge-0903

This commit is contained in:
Merge Sim
2026-09-03 19:02:33 -07:00
22 changed files with 396 additions and 157 deletions
+7
View File
@@ -13,3 +13,10 @@ description = "Cloud SQL rollout lease holder keys in the action's unit tests"
regexTarget = "secret"
paths = ['''\.github/actions/cloud-sql-rollout-lease/[a-z-]+\.test\.mjs$''']
regexes = ['''^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/[0-9]+$''']
# RFC 6455 §1.3 example handshake nonce ("the sample nonce" in base64), sent by the raw-socket
# upgrade tests; the generic key rule reads any base64 header value as a secret.
[[allowlists]]
description = "RFC 6455 example Sec-WebSocket-Key in upgrade tests"
regexTarget = "secret"
regexes = ['''^dGhlIHNhbXBsZSBub25jZQ==$''']
@@ -118,6 +118,39 @@ describe('PostgreSQL schema startup', () => {
expect(query).toHaveBeenCalledTimes(2)
})
it.each([
['42710', 'CREATE TABLE IF NOT EXISTS test'],
['42P07', 'CREATE TABLE IF NOT EXISTS test'],
['42P07', 'CREATE INDEX IF NOT EXISTS test_index ON test(id)'],
['42P07', 'CREATE UNIQUE INDEX IF NOT EXISTS test_index ON test(id)']
])('retries the committed-winner %s collision for %s', async (code, statement) => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const collision = Object.assign(new Error('already exists'), { code })
const query = vi
.fn<(statement: string) => Promise<unknown>>()
.mockRejectedValueOnce(collision)
.mockResolvedValue(undefined)
await applyPostgresSchema([statement], query, { wait: async () => undefined })
expect(query).toHaveBeenCalledTimes(2)
})
it.each([
['42710', 'CREATE INDEX IF NOT EXISTS test_index ON test(id)'],
['42710', 'CREATE TABLE test'],
['42P07', 'CREATE TABLE test'],
['42P07', 'CREATE INDEX test_index ON test(id)']
])('does not retry %s for %s', async (code, statement) => {
const error = Object.assign(new Error('already exists'), { code })
const query = vi.fn<(statement: string) => Promise<unknown>>().mockRejectedValue(error)
const pause = vi.fn(async () => undefined)
await expect(applyPostgresSchema([statement], query, { wait: pause })).rejects.toBe(error)
expect(pause).not.toHaveBeenCalled()
})
it.each([
['pg_type_typname_nsp_index', 'CREATE TABLE test'],
['pg_class_relname_nsp_index', 'CREATE INDEX test_index ON test(id)']
@@ -34,22 +34,28 @@ describePostgres('PostgreSQL schema concurrency', () => {
})
it('opens five directors when one new table is absent', async () => {
const initial = await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' })
await initial.query(`DROP TABLE relay_cell_legacy_fence_adoptions`)
await initial.close()
// Which catalog step the race loser fails on depends on scheduling, so run several rounds and
// keep the loser's SQLSTATE in the failure instead of a bare boolean.
for (let round = 0; round < 10; round += 1) {
const initial = await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' })
await initial.query(`DROP TABLE relay_cell_legacy_fence_adoptions`)
await initial.close()
const results = await Promise.allSettled(
Array.from({ length: 5 }, async (): Promise<RelayDatabase> =>
await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' })
const results = await Promise.allSettled(
Array.from({ length: 5 }, async (): Promise<RelayDatabase> =>
await openRelayDatabase({ databaseUrl: scopedUrl, dataDir: '' })
)
)
const databases = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : []
)
)
const databases = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : []
)
try {
expect(results.every((result) => result.status === 'fulfilled')).toBe(true)
} finally {
await Promise.all(databases.map(async (database) => await database.close()))
const rejections = results.flatMap((result) =>
result.status === 'rejected'
? [{ round, code: (result.reason as { code?: unknown }).code, message: String(result.reason) }]
: []
)
expect(rejections).toEqual([])
}
})
}, 60_000)
})
@@ -22,15 +22,37 @@ function wait(delayMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, delayMs))
}
const CREATE_TABLE_IF_NOT_EXISTS = /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i
const CREATE_INDEX_IF_NOT_EXISTS = /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i
// `IF NOT EXISTS` only checks the name before the catalog inserts, so the loser of a concurrent
// CREATE can fail on the catalog unique index (23505) or, when the winner has already committed by
// the time the loser reaches TypeCreate/heap_create_with_catalog, on the name check those routines
// repeat (42710 duplicate type, 42P07 duplicate relation). Each is a no-op on the next attempt.
function concurrentCreateCollision(
value: { code?: unknown; constraint?: unknown },
statement: string
): boolean {
if (CREATE_TABLE_IF_NOT_EXISTS.test(statement)) {
return (
(value.code === '23505' && value.constraint === 'pg_type_typname_nsp_index') ||
value.code === '42710' ||
value.code === '42P07'
)
}
if (CREATE_INDEX_IF_NOT_EXISTS.test(statement)) {
return (
(value.code === '23505' && value.constraint === 'pg_class_relname_nsp_index') ||
value.code === '42P07'
)
}
return false
}
function retryableSchemaError(error: unknown, statement: string): boolean {
const value = error as { code?: unknown; constraint?: unknown }
return (
RETRYABLE_SCHEMA_CODES.has(String(value.code)) ||
(value.code === '23505' &&
((value.constraint === 'pg_type_typname_nsp_index' &&
/^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i.test(statement)) ||
(value.constraint === 'pg_class_relname_nsp_index' &&
/^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i.test(statement))))
RETRYABLE_SCHEMA_CODES.has(String(value.code)) || concurrentCreateCollision(value, statement)
)
}
+13 -3
View File
@@ -31,6 +31,16 @@ import { createRelayTokenVerifier, readBearer } from './relay-token-verifier.js'
import { closeRelayWebSocket } from './relay-websocket-close.js'
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
// A malformed percent-escape in the request target must be a client error, never a URIError
// thrown out of the `upgrade` listener (which is uncaught and kills the process).
function decodePathSegment(value: string): string | null {
try {
return decodeURIComponent(value)
} catch {
return null
}
}
function rejectUpgrade(socket: NodeJS.WritableStream, status: number, message: string): void {
socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
if ('destroy' in socket && typeof socket.destroy === 'function') socket.destroy()
@@ -278,8 +288,8 @@ export function createRelayServer(
return
}
if (url.pathname.startsWith('/v1/connect/')) {
const hostId = decodeURIComponent(url.pathname.slice('/v1/connect/'.length))
if (!/^[A-Za-z0-9_-]{16}$/.test(hostId)) {
const hostId = decodePathSegment(url.pathname.slice('/v1/connect/'.length))
if (hostId === null || !/^[A-Za-z0-9_-]{16}$/.test(hostId)) {
rejectUpgrade(socket, 429, 'Too Many Requests')
return
}
@@ -373,7 +383,7 @@ export function createRelayServer(
rejectUpgrade(socket, 404, 'Not Found')
return
}
const connId = decodeURIComponent(url.pathname.slice('/v1/host/data/'.length))
const connId = decodePathSegment(url.pathname.slice('/v1/host/data/'.length))
if (!connId || connId.length > 128) {
rejectUpgrade(socket, 429, 'Too Many Requests')
return
@@ -0,0 +1,122 @@
import { connect, createServer as createNetServer } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RelayConfig } from './config.js'
import type { RelayDatabase } from './database.js'
import { createRelayServer } from './relay-server.js'
async function unusedPort(): Promise<number> {
const server = createNetServer()
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('missing test port')
await new Promise<void>((resolve) => server.close(() => resolve()))
return address.port
}
function rawUpgrade(port: number, target: string): Promise<{ status: string; closed: boolean }> {
return new Promise((resolve, reject) => {
const socket = connect(port, '127.0.0.1')
let data = ''
socket.once('connect', () => {
socket.write(
`GET ${target} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: Upgrade\r\n` +
'Upgrade: websocket\r\nSec-WebSocket-Version: 13\r\n' +
// RFC 6455 §1.3 example nonce; allowlisted in cloud/.gitleaks.toml.
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n'
)
})
socket.on('data', (chunk) => {
data += chunk.toString()
})
socket.once('close', () => resolve({ status: data.split('\r\n')[0] ?? '', closed: true }))
socket.once('error', reject)
setTimeout(() => {
socket.destroy()
resolve({ status: data.split('\r\n')[0] ?? '', closed: false })
}, 1_500).unref()
})
}
describe('relay upgrade with a malformed request target', () => {
const cleanup: Array<() => Promise<void> | void> = []
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) await close()
vi.restoreAllMocks()
})
it('rejects an undecodable /v1/connect path without an uncaught exception', async () => {
const port = await unusedPort()
const relayUrl = `http://127.0.0.1:${port}`
const database: RelayDatabase = {
query: vi.fn(async () => []),
queryLocked: vi.fn(async () => []),
transaction: vi.fn(async (operation) => await operation(database)),
close: vi.fn(async () => undefined)
}
const config = {
port,
publicUrl: relayUrl,
cellUrl: relayUrl,
authIssuer: 'https://auth.example.com',
authAudience: 'orca-relay',
jwksUrl: 'https://auth.example.com/jwks',
assignmentSigningKey: new Uint8Array(32),
role: 'cell',
cellId: 'production-gce-c3',
cells: [{ id: 'production-gce-c3', url: relayUrl, capacityRequests: 4_000 }],
adminAudience: `${relayUrl}/admin`,
deployServiceAccount: 'deploy@example.com',
runtimeServiceAccount: 'runtime@example.com',
connectionHardCap: 600,
connectionUnobservedBound: 60,
adminJwksUrl: 'https://auth.example.com/admin-jwks',
databasePoolMax: 10,
publicAssignmentsEnabled: true,
publicAssignmentConcurrency: 2,
publicAssignmentQueueMax: 128,
publicAssignmentWaitMs: 4_000,
publicResolveConcurrency: 1,
publicResolveWaitMs: 5_000,
publicAssignmentRetryAfterSeconds: 5,
dataDir: './test-data'
} satisfies RelayConfig
const relay = createRelayServer(config, database, {
connectionLedgerLimits: { hardCap: 5, controlReserve: 1 }
})
relay.server.listen(port, '127.0.0.1')
await new Promise<void>((resolve) => relay.server.once('listening', resolve))
cleanup.push(() => new Promise<void>((resolve) => relay.server.close(() => resolve())))
vi.spyOn(console, 'log').mockImplementation(() => undefined)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
// Vitest installs its own uncaughtException listener; capture ours first so the test reports
// the exception as a verdict instead of dying with it.
const uncaught: unknown[] = []
const onUncaught = (error: unknown): void => {
uncaught.push(error)
}
process.prependListener('uncaughtException', onUncaught)
cleanup.push(() => {
process.off('uncaughtException', onUncaught)
})
const results = []
for (const target of [
'/v1/connect/%',
'/v1/connect/%E0%A4%A',
'/v1/connect/%C0%AF',
'/v1/host/data/%'
]) {
results.push(await rawUpgrade(port, target))
}
// A malformed percent-escape must be a client error, never a process-level throw.
expect(uncaught).toEqual([])
for (const result of results) {
expect(result.status).toMatch(/^HTTP\/1\.1 4\d\d/)
}
// The server must still serve a well-formed upgrade afterwards.
const after = await rawUpgrade(port, '/v1/connect/abcdefghijklmnop')
expect(after.status).toMatch(/^HTTP\/1\.1 101/)
})
})
@@ -55,6 +55,13 @@
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/appearance-search.ts",
"kind": "object-property:keywords",
"text": "Langue",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts",
"kind": "object-property:keywords",
@@ -63,11 +63,11 @@ const HOST_COMPONENT_NAMES = new Set([
])
const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6'
const HEAD_HOOK_BINDING_SHA256 = 'ecd4c1dad066cf13698447b8ffb61f82e6cc3ebe7d484f71189626efed430272'
const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1'
const HEAD_CALLBACK_IDENTITY_SHA256 =
'df073bc13d94a93e7fbd8b1fca2b57eaf43cbf7ca799a649e0ebb783e5b8eecc'
const HEAD_CALLBACK_BODY_SHA256 = '690e3069e08ecf805af726b658e900c973565259160f25e3a643175e2ab1bc75'
const HEAD_EFFECT_SHA256 = '346d384ea0bf2f8f926c5092c5bf57bc2a03494f49f9639e9d6b8a2c51c9f882'
'2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb'
const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9'
const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13'
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
const HEAD_NESTED_FUNCTION_SHA256 =
'6a13919ede2a8033436fb03e0ff7c426fbed97f470875a7b21b00aaada17fb73'
@@ -79,13 +79,13 @@ const HEAD_TIMER_CREATION_SHA256 =
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
const HEAD_RUNTIME_STRING_SHA256 =
'1cb95fe0095c1c57e1b0629472e1cce5328eb7f5bfeca38095f41f4612a37887'
'ba52a3ede721bd29acbe8593161e90b216b7f361ff896e085927d4d73fa83b2f'
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016'
const HEAD_STYLE_REFERENCE_SHA256 =
'295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
const HEAD_IDENTITY_FIELD_SHA256 =
'6b37a0351795a387a358df76a5ab919a7098ddb76bf25a936c8902c062c8951c'
'91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6'
const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512'
const HEAD_CAPABILITY_SHA256 = 'ca219f7909a091717110b823d5b94a20770ad3ae51894e0fa765e8628309392d'
@@ -517,7 +517,7 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
expect(strings).toHaveLength(545)
expect(strings).toHaveLength(546)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(124)
@@ -29,6 +29,14 @@ const tabReconciliationOwnerSource = readMobileSessionRouteSource(
const autoCreateHookSource = readMobileSessionRouteSource(
'./use-initial-session-terminal-autocreate.ts'
)
const foundationSource = readMobileSessionRouteSource('./use-mobile-session-foundation.ts')
const terminalRuntimeSource = readMobileSessionRouteSource(
'./use-mobile-session-terminal-runtime.ts'
)
const terminalSubscriptionSourceForIdentity = readMobileSessionRouteSource(
'./use-mobile-session-terminal-subscription.ts'
)
const lifecycleSource = readMobileSessionRouteSource('./use-mobile-session-lifecycle.ts')
function sliceBetween(startPattern: string, endPattern: string, targetSource = source): string {
const start = targetSource.indexOf(startPattern)
@@ -106,6 +114,19 @@ describe('mobile session startup', () => {
expect(reconciliationHookSource).toContain('appStateSubscription.remove()')
})
it('binds terminal identity to the shared client before subscription effects run', () => {
expect(foundationSource).toContain('const { client, clientId, state: connState }')
expect(foundationSource).toContain(' clientId,')
expect(terminalRuntimeSource).toContain('useRef<string | null>(clientId)')
expect(terminalRuntimeSource).toContain('deviceTokenRef.current = clientId')
expect(terminalRuntimeSource).toContain('inputGate.canSend && clientId !== null')
expect(terminalSubscriptionSourceForIdentity).toContain('if (clientId === null)')
expect(terminalSubscriptionSourceForIdentity).toContain(
"client: { id: clientId, type: 'mobile' as const }"
)
expect(lifecycleSource).not.toContain('deviceTokenRef.current = host.deviceToken')
})
it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => {
expect(terminalSubscriptionSource).toContain(
"if (data.type === 'end' || data.type === 'error')"
@@ -35,7 +35,7 @@ export function useMobileSessionFoundation() {
const router = useRouter()
const insets = useSafeAreaInsets()
// Why: shared client per host owned by RpcClientProvider (docs/mobile-shared-client-per-host.md).
const { client, state: connState } = useHostClient(hostId)
const { client, clientId, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const forceReconnectHost = useForceReconnect()
@@ -96,6 +96,7 @@ export function useMobileSessionFoundation() {
router,
insets,
client,
clientId,
connState,
reconnectAttempts,
lastConnectedAt,
@@ -16,7 +16,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
connState,
setCustomKeys,
setVisibleBuiltInIds,
deviceTokenRef,
setHostEndpoint,
connStateRef,
terminalRefs,
@@ -26,7 +25,7 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
unsubscribeTerminal,
subscribeToTerminal
} = scope
// Why: read deviceToken from host record so code can pass client.id on subscribe/send for driver-state-machine identity.
// Why: the shared client owns authenticated identity; this host read only supplies connection-hint metadata.
useEffect(() => {
if (!hostId) {
return
@@ -38,7 +37,6 @@ export function useMobileSessionLifecycle(scope: MobileSessionTabReconciliationM
}
const host = hosts.find((h) => h.id === hostId)
if (host) {
deviceTokenRef.current = host.deviceToken
setHostEndpoint(host.endpoint)
}
})
@@ -27,6 +27,7 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
worktreeId,
connState,
client,
clientId,
sessionTabs,
setLiveInputCapture,
liveInputTerminalHandles,
@@ -42,7 +43,9 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
const terminalGestureInputInFlightRef = useRef<Set<string>>(new Set())
const terminalCwdRef = useRef<Map<string, string>>(new Map())
const initialModesSeenRef = useRef<Set<string>>(new Set())
const deviceTokenRef = useRef<string | null>(null)
const deviceTokenRef = useRef<string | null>(clientId)
// Keep the authenticated identity synchronous with the client exposed to downstream hooks.
deviceTokenRef.current = clientId
// Why: state (not a ref) so the connection verdict re-renders when the endpoint loads and the Tailscale hint can appear.
const [hostEndpoint, setHostEndpoint] = useState<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
@@ -123,11 +126,13 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
sendLiveTerminalInputRef,
setLiveInputCapture
})
const { canCompose, canSend } = resolveMobileTerminalInputGate({
const inputGate = resolveMobileTerminalInputGate({
connState,
activeHandle,
activeSessionTabType: activeSessionTab?.type
})
const canCompose = inputGate.canCompose
const canSend = inputGate.canSend && clientId !== null
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({
activeHandleRef,
@@ -15,9 +15,9 @@ export function useMobileSessionTerminalSubscription(
) {
const {
client,
clientId,
setTerminalModes,
terminalCwdRef,
deviceTokenRef,
viewportRef,
viewportMeasuredRef,
terminalUnsubsRef,
@@ -49,6 +49,10 @@ export function useMobileSessionTerminalSubscription(
logSkippedGate('no-client')
return
}
if (clientId === null) {
logSkippedGate('no-client-identity')
return
}
if (terminalUnsubsRef.current.has(handle)) {
logSkippedGate('already-subscribed')
return
@@ -89,7 +93,7 @@ export function useMobileSessionTerminalSubscription(
client,
{
terminal: handle,
client: { id: deviceTokenRef.current!, type: 'mobile' as const },
client: { id: clientId, type: 'mobile' as const },
viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport(
covered,
viewportRef.current
@@ -263,6 +267,7 @@ export function useMobileSessionTerminalSubscription(
},
[
client,
clientId,
getTerminalRef,
markNativeChatInputLeaseReady,
scheduleDelayedAction,
+7 -3
View File
@@ -146,8 +146,8 @@ beforeEach(() => {
})
describe('useHostClient', () => {
it('rebinds when Expo reuses a screen between two connected cached hosts', async () => {
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
it('rebinds the client and its authenticated identity together across cached hosts', async () => {
const host2 = { ...HOST, id: 'host-2', name: 'Host 2', deviceToken: 'token-2' }
const client1 = makeFakeClient('connected')
const client2 = makeFakeClient('connected')
connectMock.mockReturnValueOnce(client1).mockReturnValueOnce(client2)
@@ -155,11 +155,13 @@ describe('useHostClient', () => {
let selectedHostId = HOST.id
let selectedClient: RpcClient | null = null
let selectedClientId: string | null = null
let selectedState: ConnectionState = 'disconnected'
let renderer: ReactTestRenderer | null = null
function Probe(): null {
const selected = useHostClient(selectedHostId)
selectedClient = selected.client
selectedClientId = selected.clientId
selectedState = selected.state
useHostClient(host2.id)
return null
@@ -171,16 +173,18 @@ describe('useHostClient', () => {
await Promise.resolve()
})
expect(selectedClient).toBe(client1)
expect(selectedClientId).toBe(HOST.deviceToken)
expect(selectedState).toBe('connected')
selectedHostId = host2.id
client2.emitState('disconnected')
await act(async () => {
client2.emitState('disconnected')
renderer?.update(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(selectedClient).toBe(client2)
expect(selectedClientId).toBe(host2.deviceToken)
expect(selectedState).toBe('disconnected')
expect(connectMock).toHaveBeenCalledTimes(2)
} finally {
+9 -97
View File
@@ -7,7 +7,6 @@ import {
useEffect,
useMemo,
useRef,
useState,
type ReactNode
} from 'react'
import type { RpcClient } from './rpc-client'
@@ -35,6 +34,15 @@ import {
import type { ConnectionState, HostProfile } from './types'
import type { RpcClientContextValue } from './rpc-client-context-contract'
export {
useDisconnectHostClient,
useForceReconnect,
useForgetHostClient,
useHostClient,
usePrimeHosts,
useRefreshHostClient
} from './host-client-hooks'
type StoreEntry = HostClientStoreEntry
const Ctx = createContext<RpcClientContextValue | null>(null)
@@ -364,99 +372,3 @@ export function useRpcClientContext(): RpcClientContextValue {
}
return ctx
}
// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change.
export function useHostClient(hostId: string | undefined): {
client: RpcClient | null
state: ConnectionState
} {
const ctx = useRpcClientContext()
const [, force] = useState(0)
// Why: an absent entry at mount is almost always the open racing the render, not a
// dead host — seed amber; a failed open notifies 'disconnected' moments later.
const [state, setState] = useState<ConnectionState>(() =>
hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected'
)
const clientRef = useRef<RpcClient | null>(null)
const clientHostIdRef = useRef<string | undefined>(hostId)
const acquisitionRef = useRef<HostClientAcquisition>({})
useEffect(() => {
if (!hostId) {
clientRef.current = null
clientHostIdRef.current = undefined
setState('disconnected')
return
}
clientHostIdRef.current = hostId
let cancelled = false
// Subscribe before acquire so any state change during open is captured.
const unsub = ctx.subscribeHostState(hostId, (next) => {
if (cancelled) {
return
}
setState(next)
// Why: async open and forceReconnect swap the client object; re-read each state change so screens never drive a stale one.
const found = ctx.getAllClients().find((entry) => entry.hostId === hostId)
if (found && found.client !== clientRef.current) {
clientRef.current = found.client
force((n) => n + 1)
} else if (!found && clientRef.current) {
// Why: disconnect/forget deletes the entry; never retain a dead client (STA-1511).
clientRef.current = null
force((n) => n + 1)
}
})
const initial = ctx.acquire(hostId, acquisitionRef.current)
clientRef.current = initial
setState(ctx.getKnownState(hostId) ?? 'connecting')
if (initial) {
// Why: two cached hosts can both be connected, so equal state values cannot reveal the replacement client.
force((n) => n + 1)
}
return () => {
cancelled = true
unsub()
ctx.release(hostId, acquisitionRef.current)
clientRef.current = null
clientHostIdRef.current = undefined
}
}, [ctx, hostId])
// Why: Expo can reuse the screen before effects bind the next host; never expose the prior host's client or state in that render.
const bound = clientHostIdRef.current === hostId
const boundState = bound
? state
: hostId
? (ctx.getKnownState(hostId) ?? 'connecting')
: 'disconnected'
return { client: bound ? clientRef.current : null, state: boundState }
}
// Why: host-store's removeHost() must close the live client but has no React-side handle; this hook bridges to it.
export function useRefreshHostClient(): (hostId: string) => void {
const ctx = useRpcClientContext()
return ctx.refreshHostClient
}
export function useForgetHostClient(): (hostId: string) => void {
const ctx = useRpcClientContext()
return ctx.forgetHostClient
}
export function useDisconnectHostClient(): (hostId: string) => void {
const ctx = useRpcClientContext()
return ctx.disconnectHostClient
}
// Why: future-proof "Connection issues — try again" affordance.
export function useForceReconnect(): (hostId: string) => Promise<void> {
const ctx = useRpcClientContext()
return ctx.forceReconnect
}
// Why: primes already-loaded HostProfiles so the provider can skip a second loadHosts()/Keychain pass on cold start.
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
const ctx = useRpcClientContext()
return ctx.primeHosts
}
@@ -79,6 +79,7 @@ export function createHostClientSelectors(
return {
getKnownState,
getState: (hostId: string): ConnectionState => getKnownState(hostId) ?? 'disconnected',
getClientId: (hostId: string): string | null => entries.get(hostId)?.clientId ?? null,
getReconnectAttempt: (hostId: string): number =>
entries.get(hostId)?.client.getReconnectAttempt() ?? 0,
getLastConnectedAt: (hostId: string): number | null =>
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef, useState } from 'react'
import type { RpcClient } from './rpc-client'
import type { ConnectionState, HostProfile } from './types'
import type { HostClientAcquisition } from './host-client-acquisition-registry'
import { useRpcClientContext } from './client-context'
// Primary hook for screens: acquires the shared client on mount, releases on unmount, re-renders on state change.
export function useHostClient(hostId: string | undefined): {
client: RpcClient | null
clientId: string | null
state: ConnectionState
} {
const ctx = useRpcClientContext()
const [, force] = useState(0)
const [state, setState] = useState<ConnectionState>(() =>
hostId ? (ctx.getKnownState(hostId) ?? 'connecting') : 'disconnected'
)
const clientRef = useRef<RpcClient | null>(null)
const clientHostIdRef = useRef<string | undefined>(hostId)
const acquisitionRef = useRef<HostClientAcquisition>({})
useEffect(() => {
if (!hostId) {
clientRef.current = null
clientHostIdRef.current = undefined
setState('disconnected')
return
}
clientHostIdRef.current = hostId
let cancelled = false
const unsub = ctx.subscribeHostState(hostId, (next) => {
if (cancelled) {
return
}
setState(next)
const found = ctx.getAllClients().find((entry) => entry.hostId === hostId)
if (found && found.client !== clientRef.current) {
clientRef.current = found.client
force((n) => n + 1)
} else if (!found && clientRef.current) {
clientRef.current = null
force((n) => n + 1)
}
})
const initial = ctx.acquire(hostId, acquisitionRef.current)
clientRef.current = initial
setState(ctx.getKnownState(hostId) ?? 'connecting')
if (initial) {
force((n) => n + 1)
}
return () => {
cancelled = true
unsub()
ctx.release(hostId, acquisitionRef.current)
clientRef.current = null
clientHostIdRef.current = undefined
}
}, [ctx, hostId])
const bound = clientHostIdRef.current === hostId
const boundClient = bound ? clientRef.current : null
const boundState = bound
? state
: hostId
? (ctx.getKnownState(hostId) ?? 'connecting')
: 'disconnected'
return {
client: boundClient,
clientId: boundClient && hostId ? ctx.getClientId(hostId) : null,
state: boundState
}
}
export function useRefreshHostClient(): (hostId: string) => void {
return useRpcClientContext().refreshHostClient
}
export function useForgetHostClient(): (hostId: string) => void {
return useRpcClientContext().forgetHostClient
}
export function useDisconnectHostClient(): (hostId: string) => void {
return useRpcClientContext().disconnectHostClient
}
export function useForceReconnect(): (hostId: string) => Promise<void> {
return useRpcClientContext().forceReconnect
}
export function usePrimeHosts(): (hosts: HostProfile[]) => void {
return useRpcClientContext().primeHosts
}
@@ -12,6 +12,7 @@ import type { ConnectionState, HostProfile } from './types'
export type HostClientStoreEntry = {
client: RpcClient
clientId: string
state: ConnectionState
refCount: number
unsubState: () => void
@@ -130,6 +131,7 @@ export async function openHostClientEntry(
}) ?? (() => {})
const entry: HostClientStoreEntry = {
client,
clientId: host.deviceToken,
state: client.getState(),
refCount: state.pendingAcquisitions.get(hostId) ?? 0,
unsubState,
@@ -18,6 +18,7 @@ export type RpcClientContextValue = {
disconnectHostClient: (hostId: string) => void
getState: (hostId: string) => ConnectionState
getKnownState: (hostId: string) => ConnectionState | null
getClientId: (hostId: string) => string | null
getReconnectAttempt: (hostId: string) => number
getLastConnectedAt: (hostId: string) => number | null
getActivePath: (hostId: string) => MobileConnectionPath
@@ -7,14 +7,14 @@ import { matchesSettingsSearch } from './settings-search'
// Native word for "language" in each supported UI language. These must be
// findable no matter which locale the interface is currently rendered in, so a
// speaker can locate (and switch to) their language from any starting point.
const NATIVE_LANGUAGE_WORDS = ['语言', '語言', '언어', '言語', 'Idioma']
const NATIVE_LANGUAGE_WORDS = ['语言', '語言', '언어', '言語', 'Idioma', 'Langue']
describe('getLanguageEntries', () => {
afterEach(async () => {
await i18n.changeLanguage('en')
})
it.each(['en', 'zh', 'ko', 'ja', 'es'])(
it.each(['en', 'zh', 'ko', 'ja', 'es', 'fr'])(
'indexes every native word for "language" under the %s UI locale',
async (locale) => {
await i18n.changeLanguage(locale)
@@ -29,4 +29,9 @@ describe('getLanguageEntries', () => {
await i18n.changeLanguage('en')
expect(matchesSettingsSearch('Español', getLanguageEntries()[0])).toBe(true)
})
it('matches the French native language name in English UI', async () => {
await i18n.changeLanguage('en')
expect(matchesSettingsSearch('Français', getLanguageEntries()[0])).toBe(true)
})
})
@@ -50,6 +50,7 @@ export const getLanguageEntries = createLocalizedCatalog((): SettingsSearchEntry
...translateSearchKeyword('settings.appearance.language.korean', '한국어'),
...translateSearchKeyword('settings.appearance.language.japanese', '日本語'),
...translateSearchKeyword('settings.appearance.language.spanish', 'Español'),
...translateSearchKeyword('settings.appearance.language.french', 'Français'),
// Why: the native word for "language" only reaches search via the localized
// title in its own UI locale — index each here so speakers can find (and
// switch to) their language whatever the current interface locale is.
@@ -58,6 +59,7 @@ export const getLanguageEntries = createLocalizedCatalog((): SettingsSearchEntry
'언어', // Korean
'言語', // Japanese
'Idioma', // Spanish
'Langue', // French
...translateSearchKeyword(
'auto.components.settings.appearance.search.language.locale',
'locale'
-17
View File
@@ -2934,7 +2934,6 @@
"a7e2fd2699": "signaler un problème",
"5c8ce20be6": "Si le problème persiste, veuillez",
"cc6d997c65": "Redémarrez le daemon de terminal depuis ici pour effacer un état de daemon obsolète.",
"7ee11bc0db": "Orca n'a pas pu confirmer si la session précédente de ce terminal tourne encore ; il a donc laissé la session intacte. Rouvrez ce volet pour réessayer.",
"e16012e31e": "Le daemon de terminal propriétaire de cette session s'est arrêté ; la session et son historique de défilement n'ont pas pu être récupérés. Ouvrez un nouveau terminal pour continuer.",
"sessionUnavailable": "Orca n'a pas pu se rattacher à la session de terminal de ce volet sur l'hôte. Ouvrez un nouveau terminal pour continuer."
},
@@ -3228,7 +3227,6 @@
"25dc1cd653": "Ouvrir le fichier",
"7cdf8ee0c8": "Ouvrir une URL",
"b27864279e": "Lancer un agent",
"0e5b7a3f16": "Rechercher parmi les onglets ouverts, fichiers, URL et agents…",
"8f0a1c4d92": "Basculer vers l'onglet",
"2c38630a01": "L'espace de travail n'existe plus",
"4f0d9a71c2": "L'onglet n'existe plus",
@@ -3274,7 +3272,6 @@
"classifier": {
"42e6262ae9": "Aucune action disponible.",
"097a982ee0": "Chargement des fichiers...",
"c41f8d20b7": "Rechercher parmi les onglets ouverts, fichiers, URL et agents…",
"90eb94dc48": "Saisissez une URL http:// ou https://.",
"5553b283ce": "Saisissez une URL ou un chemin de fichier.",
"queryTooLarge": "Le texte recherché est trop long.",
@@ -6999,12 +6996,6 @@
"compareBaseRepositoryDefault": "Valeur par défaut du dépôt",
"compareBaseBranchUpstream": "Amont de la branche"
},
"HiddenExperimentalGroup": {
"d0f914a528": "Bascule fictive",
"1014ddbfaf": "Sans effet aujourd'hui. Réservée comme premier emplacement pour les options expérimentales masquées.",
"232cf83de8": "Bascules non répertoriées pour les tests internes. Rien ici n'est pris en charge.",
"3e9e827ca5": "Expérimental masqué"
},
"InputPane": {
"db15068196": "Activé par défaut sur Linux et macOS. Linux utilise le presse-papiers de sélection du système ; les autres plateformes utilisent un tampon privé.",
"ad31c3c5fb": "Collage de la sélection au clic milieu"
@@ -11832,10 +11823,6 @@
"b3c8f1a902": "Filtrer les fichiers par nom",
"d4f8c2a901": "Effacer et fermer le filtre",
"e8a1c4b203": "vs",
"f9b2441bb6": "1 commit d'avance sur {{value0}}",
"b715ef615b": "{{value0}} commits d'avance sur {{value1}}",
"c1a8f3e204": "1 commit de retard sur {{value0}}",
"d2b9g4f315": "{{value0}} commits de retard sur {{value1}}",
"4b4a7de138": "Ouvrir la page de revue dans le navigateur",
"createPrIntentCommitBlockedSummary": "Commit bloqué : {{value0}} Corrigez le problème, puis réessayez Créer une PR.",
"pushRecovery": {
@@ -11858,7 +11845,6 @@
"a4e93c21d7": "Branche actuelle : {{value0}}",
"c7d4e2f801": "Modifier la ref de base : {{value0}}",
"f3a1b8c204": "upstream",
"createPrIntentEmptyGeneratedBody": "Les détails de revue générés n'incluent pas de description. Réessayez Créer une PR.",
"createPrIntentGenerateDetailsFailed": "Impossible de générer les détails de revue. Réessayez Créer une PR."
},
"SourceControlAgentActionDialog": {
@@ -14038,8 +14024,6 @@
"7e7ca60816": "fichiers modifiés",
"b6c3b84476": "Afficher l'arborescence des fichiers",
"39f8007549": "Examiner les conflits",
"39e73e7181": "ont été exclus de cette vue de diff.",
"689b99f8ad": "conflit non résolu",
"820ec01f24": "Les fichiers en conflit sont examinés séparément",
"fd8892b120": "Aucune modification à afficher",
"eb5f40e49c": "Cette vue de diff exclut les conflits non résolus, car le pipeline de diff bidirectionnel habituel n'est pas conçu pour gérer les conflits.",
@@ -15793,7 +15777,6 @@
},
"LinuxPackageInstallRecoveryCard": {
"e3de29c86a": "Afficher le paquet",
"3da99454c6": "Réessayer l'installation automatique",
"55c86654b7": "Copier la commande d'installation",
"53e1559f99": "Échec de l'installation automatique",
"a7ac6ec78b": "Orca a téléchargé la mise à jour mais n'a pas pu installer le paquet système automatiquement.",