mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
feat(mobile): iPhone Duo key rail with D-pad, split terminal panes and half-fold layout (#35177)
Carries over #33221 and #33210. Replaces our #34484 Duo support with the community design, on top of main's rebuilt terminal key bar (#36166). Fixes #33200 Co-authored-by: Takeshi Matsumoto <zytakeshi520@gmail.com> Co-authored-by: Lars Keggenhoff <lars@kghf.io>
This commit is contained in:
co-authored by
Takeshi Matsumoto
Lars Keggenhoff
parent
0eaa1f9049
commit
a803cbead8
@@ -26,8 +26,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
ios-build:
|
||||
# Use macos-26/Xcode 26 for Expo SDK 55’s Swift 6 syntax/isolation; Xcode 16 runners cannot parse it.
|
||||
runs-on: macos-26
|
||||
# Xcode 27.1's iOS SDK enables iPhone Duo resizing; Expo SDK 55 requires Swift 6 syntax.
|
||||
runs-on: xcode-27
|
||||
# Archive + upload is ~30–40m when healthy; 90m leaves margin for setup.
|
||||
timeout-minutes: 90
|
||||
|
||||
@@ -50,8 +50,8 @@ jobs:
|
||||
- name: Select Xcode
|
||||
uses: maxim-lobanov/setup-xcode@v1
|
||||
with:
|
||||
# Xcode 26.x ships the Swift 6.x toolchain Expo SDK 55 requires.
|
||||
xcode-version: '26.5'
|
||||
# iOS 27.1 SDK: iPhone Duo resizing and reserved (fold/camera) regions.
|
||||
xcode-version: '27.1'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -450,7 +450,8 @@ export function resolveMobileWebPageRoutes(routeKeys, declared = MOBILE_WEB_PAGE
|
||||
...(route.optionalGrants === undefined || route.optionalGrants.length === 0
|
||||
? {}
|
||||
: { optionalGrants: [...route.optionalGrants] }),
|
||||
...(route.canOwnHostArea === true ? { canOwnHostArea: true } : {})
|
||||
...(route.canOwnHostArea === true ? { canOwnHostArea: true } : {}),
|
||||
...(route.canOwnHostAreaAcrossLayouts === true ? { canOwnHostAreaAcrossLayouts: true } : {})
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
{
|
||||
pathname: '/h/[hostId]',
|
||||
grants: ['navigate', 'storage', 'externalLink', 'haptics'],
|
||||
canOwnHostArea: true
|
||||
canOwnHostArea: true,
|
||||
canOwnHostAreaAcrossLayouts: true
|
||||
},
|
||||
// History needs navigate for session/resume, storage for shared sidebar width and externalLink for layout protocol wall.
|
||||
{
|
||||
@@ -75,7 +76,12 @@ export const MOBILE_WEB_PAGE_ROUTES = [
|
||||
'native.audio.read',
|
||||
'native.audio.stop'
|
||||
],
|
||||
// Optional media.name preserves attachment on older shells by falling back to filename derived from type.
|
||||
optionalGrants: ['externalNavigation', 'native.media.name', 'native.pdf.view']
|
||||
// Optional media.name falls back to the file type; terminalKeyRail falls back to the page's row.
|
||||
optionalGrants: [
|
||||
'externalNavigation',
|
||||
'native.media.name',
|
||||
'native.pdf.view',
|
||||
'terminalKeyRail'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MOBILE_WEB_BUNDLE_MAX_GRANTED_ROUTE_GRANTS,
|
||||
MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS,
|
||||
MobileWebBundleRouteSchema
|
||||
} from '../../src/shared/mobile-web-bundle/manifest-contract.ts'
|
||||
@@ -58,16 +59,51 @@ describe('the page routes the manifest declares', () => {
|
||||
])
|
||||
})
|
||||
|
||||
// Wide host-area grants union all served routes to retain in-page hops, within same per-route ceiling.
|
||||
it('serializes cross-layout ownership only for opted-in routes and declares it only on the host root', () => {
|
||||
const routes = resolveMobileWebPageRoutes(
|
||||
['./h/[hostId]/index.tsx', './h/[hostId]/tasks.tsx'],
|
||||
[
|
||||
{
|
||||
pathname: '/h/[hostId]',
|
||||
grants: ['navigate'],
|
||||
canOwnHostArea: true,
|
||||
canOwnHostAreaAcrossLayouts: true
|
||||
},
|
||||
{ pathname: '/h/[hostId]/tasks', grants: ['navigate'], canOwnHostArea: true }
|
||||
]
|
||||
)
|
||||
expect(JSON.parse(JSON.stringify(routes))).toEqual([
|
||||
{
|
||||
pathname: '/h/[hostId]',
|
||||
grants: ['navigate'],
|
||||
canOwnHostArea: true,
|
||||
canOwnHostAreaAcrossLayouts: true
|
||||
},
|
||||
{ pathname: '/h/[hostId]/tasks', grants: ['navigate'], canOwnHostArea: true }
|
||||
])
|
||||
expect(
|
||||
MOBILE_WEB_PAGE_ROUTES.filter((route) => route.canOwnHostAreaAcrossLayouts).map(
|
||||
(route) => route.pathname
|
||||
)
|
||||
).toEqual(['/h/[hostId]'])
|
||||
for (const route of routes) {
|
||||
expect(MobileWebBundleRouteSchema.safeParse(route).success).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* The wide iPad's host-area session is granted every served route's list, so its in-page hops
|
||||
* stay in the page. Bounded by what one route can be granted, so that grant never outgrows one route's.
|
||||
*/
|
||||
it('bounds the aggregate required and optional grants by one route-grant ceiling', () => {
|
||||
const union = new Set(
|
||||
MOBILE_WEB_PAGE_ROUTES.flatMap((route) => [...route.grants, ...(route.optionalGrants ?? [])])
|
||||
)
|
||||
expect(union.size).toBeLessThanOrEqual(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS)
|
||||
expect(union.size).toBeLessThanOrEqual(MOBILE_WEB_BUNDLE_MAX_GRANTED_ROUTE_GRANTS)
|
||||
})
|
||||
|
||||
it('holds the optional lane to the manifest grammar and the ceiling over the union', () => {
|
||||
// Optional/required tokens share schema grammar and combined bound; independent bounds could exceed manifest ceiling.
|
||||
it('holds the optional lane to the manifest grammar and its own ceiling', () => {
|
||||
// Released shells read each list against the ceiling on its own, so the writer bounds each the same way.
|
||||
const withOptional = (optionalGrants, grants = []) =>
|
||||
MobileWebBundleRouteSchema.safeParse({ pathname: '/h/[hostId]', grants, optionalGrants })
|
||||
.success
|
||||
@@ -75,8 +111,9 @@ describe('the page routes the manifest declares', () => {
|
||||
expect(withOptional(['native.externalNavigation'])).toBe(false)
|
||||
const names = (count, prefix) =>
|
||||
Array.from({ length: count }, (_value, index) => `${prefix}${String(index)}`)
|
||||
expect(withOptional(names(8, 'opt'), names(8, 'req'))).toBe(true)
|
||||
expect(withOptional(names(9, 'opt'), names(8, 'req'))).toBe(false)
|
||||
const cap = MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS
|
||||
expect(withOptional(names(cap, 'opt'), names(cap, 'req'))).toBe(true)
|
||||
expect(withOptional(names(cap + 1, 'opt'))).toBe(false)
|
||||
})
|
||||
|
||||
it('fails the build on a declaration the bundle cannot render', () => {
|
||||
|
||||
@@ -110,6 +110,8 @@
|
||||
],
|
||||
"expo-notifications",
|
||||
"./plugins/android-respect-rotation-lock.js",
|
||||
"./plugins/ios-pod-deployment-target-floor.js",
|
||||
"./plugins/ios-scene-lifecycle.js",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
|
||||
+50
-47
@@ -29,6 +29,7 @@ import { consumeInitialLaunchIntent } from '../src/navigation/initial-launch-int
|
||||
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
|
||||
import { appUpdateChecker } from '../src/app-update/app-update-runtime'
|
||||
import { SideSafeArea } from '../src/layout/side-safe-area'
|
||||
import { SideRailProvider } from '../src/layout/side-rail'
|
||||
import { useFollowDeviceUiLocale } from '../src/i18n/use-follow-device-ui-locale'
|
||||
import { startMobileUiLanguage } from '../src/i18n/ui-language-preference'
|
||||
import { subscribeMobileUiLanguage } from '../src/i18n/mobile-i18n'
|
||||
@@ -183,54 +184,56 @@ export default function RootLayout() {
|
||||
<RpcClientProvider>
|
||||
<View style={styles.root} onLayout={onNavigatorLayout}>
|
||||
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
||||
<SideSafeArea>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgPanel },
|
||||
headerTintColor: colors.textPrimary,
|
||||
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
|
||||
contentStyle: { backgroundColor: colors.bgBase },
|
||||
headerShadowVisible: false
|
||||
// Leave orientation unset: react-native-screens overrides rotation lock even for default; Android manifest fullUser honors the user’s setting.
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
headerTitle: () => <OrcaLogo size={22} />
|
||||
<SideRailProvider>
|
||||
<SideSafeArea>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgPanel },
|
||||
headerTintColor: colors.textPrimary,
|
||||
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
|
||||
contentStyle: { backgroundColor: colors.bgBase },
|
||||
headerShadowVisible: false
|
||||
// Leave orientation unset: react-native-screens overrides rotation lock even for default; Android manifest fullUser honors the user’s setting.
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="mobile-onboarding"
|
||||
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="appearance-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="notifications" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="about" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="h" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="new-workspace"
|
||||
options={{
|
||||
headerShown: false,
|
||||
// Draws nothing of its own: the drawer is the page's, over Home.
|
||||
presentation: 'transparentModal',
|
||||
animation: 'none',
|
||||
contentStyle: { backgroundColor: 'transparent' }
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</SideSafeArea>
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
headerTitle: () => <OrcaLogo size={22} />
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="mobile-onboarding"
|
||||
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="appearance-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="notifications" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="about" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="h" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="new-workspace"
|
||||
options={{
|
||||
headerShown: false,
|
||||
// Draws nothing of its own: the drawer is the page's, over Home.
|
||||
presentation: 'transparentModal',
|
||||
animation: 'none',
|
||||
contentStyle: { backgroundColor: 'transparent' }
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</SideSafeArea>
|
||||
</SideRailProvider>
|
||||
</View>
|
||||
</RpcClientProvider>
|
||||
)
|
||||
|
||||
@@ -20,21 +20,11 @@ import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-ed
|
||||
import { usePrimeHosts, useRefreshHostClient } from '../../../src/transport/host-client-hooks'
|
||||
import type { HostProfile } from '../../../src/transport/types'
|
||||
import { hostOs } from '../../../src/platform/host-os'
|
||||
import { SideSafeArea } from '../../../src/layout/side-safe-area'
|
||||
import { translate } from '../../../src/i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../../../src/i18n/use-mobile-translation'
|
||||
import { TextField } from '../../../src/components/composer-input/TextField'
|
||||
|
||||
// The host group leaves the Duo's status-bar rail to its pages; this native screen pads it.
|
||||
export default function EditHostRoute() {
|
||||
return (
|
||||
<SideSafeArea>
|
||||
<EditHostScreen />
|
||||
</SideSafeArea>
|
||||
)
|
||||
}
|
||||
|
||||
function EditHostScreen() {
|
||||
export default function EditHostScreen() {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { HostScreen } from '../../../src/host-screen/HostScreen'
|
||||
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
|
||||
import { usePageOwnsHostArea } from '../../../src/mobile-web-shell/page-owns-host-area'
|
||||
|
||||
// Render screen directly: native sibling imports a shell module that throws in browsers, and manifest imports every route.
|
||||
export default function HostWorktreeRoute() {
|
||||
// The placeholder only beside the page's own sidebar: without the host area this is the list.
|
||||
return usePageOwnsHostArea() ? <WorkspaceDetailPlaceholder /> : <HostScreen />
|
||||
const ownsHostArea = usePageOwnsHostArea()
|
||||
const { isWideLayout } = useResponsiveLayout()
|
||||
return ownsHostArea && isWideLayout ? <WorkspaceDetailPlaceholder /> : <HostScreen />
|
||||
}
|
||||
|
||||
+29
-25
@@ -5,13 +5,9 @@ import { colors } from '../../src/theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../../src/theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../../src/theme/use-mobile-color-scheme'
|
||||
import { useResponsiveLayout } from '../../src/layout/responsive-layout'
|
||||
import { useFoldSplit } from '../../src/layout/use-fold-split'
|
||||
import {
|
||||
defaultHostSidebarWidth,
|
||||
SPLIT_DETAIL_MIN_WIDTH
|
||||
} from '../../src/layout/responsive-layout-metrics'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useReleasedSideRail } from '../../src/layout/side-safe-area'
|
||||
import {
|
||||
HOST_SIDEBAR_DEFAULT_WIDTH,
|
||||
HOST_SIDEBAR_MAX_WIDTH,
|
||||
HOST_SIDEBAR_MIN_WIDTH,
|
||||
loadHostSidebarWidth,
|
||||
@@ -23,16 +19,17 @@ import { HostStack } from '../../src/navigation/host-stack'
|
||||
import { HostSidebarRevealContext } from '../../src/layout/host-sidebar-reveal'
|
||||
import { HostAreaServingContext } from '../../src/mobile-web-shell/host-area-serving'
|
||||
import { usePageOwnsHostArea } from '../../src/mobile-web-shell/page-owns-host-area'
|
||||
import { useReportedPageLocation } from '../../src/mobile-web-shell/page-location-report'
|
||||
import { isOverlayPageRoute } from '../../src/new-workspace/overlay-page-route'
|
||||
|
||||
// Keep at least this much room for the detail pane when resizing the sidebar.
|
||||
const MIN_DETAIL_WIDTH = 320
|
||||
const RESIZE_EDGE_WIDTH = 24
|
||||
|
||||
// Clamp restored sidebar width to current window so a smaller device retains usable detail space.
|
||||
function clampSidebarToWindow(width: number, windowWidth: number): number {
|
||||
const hardMax = Math.max(
|
||||
HOST_SIDEBAR_MIN_WIDTH,
|
||||
Math.min(HOST_SIDEBAR_MAX_WIDTH, windowWidth - SPLIT_DETAIL_MIN_WIDTH)
|
||||
Math.min(HOST_SIDEBAR_MAX_WIDTH, windowWidth - MIN_DETAIL_WIDTH)
|
||||
)
|
||||
return Math.min(hardMax, Math.max(HOST_SIDEBAR_MIN_WIDTH, Math.round(width)))
|
||||
}
|
||||
@@ -45,12 +42,9 @@ export default function HostGroupLayout() {
|
||||
const pathname = usePathname()
|
||||
const overlayRoute = isOverlayPageRoute(pathname)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
// Keep the dragged width and clamp per render so a fold cannot shrink it for good.
|
||||
const [savedSidebarWidth, setSidebarWidth] = useState<number | null>(null)
|
||||
const sidebarWidth = clampSidebarToWindow(
|
||||
savedSidebarWidth ?? defaultHostSidebarWidth(windowWidth),
|
||||
windowWidth
|
||||
)
|
||||
// Keep the chosen width and clamp it per window, so a narrow pose (fold, rotation) never shrinks it for good.
|
||||
const [chosenSidebarWidth, setSidebarWidth] = useState(HOST_SIDEBAR_DEFAULT_WIDTH)
|
||||
const sidebarWidth = clampSidebarToWindow(chosenSidebarWidth, windowWidth)
|
||||
|
||||
// Refs keep once-created PanResponder handlers reading current widths.
|
||||
const widthRef = useRef(sidebarWidth)
|
||||
@@ -71,21 +65,27 @@ export default function HostGroupLayout() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Host screens are pages that draw in the Duo's status-bar rail; the native sidebar pads itself.
|
||||
useReleasedSideRail(Platform.OS !== 'web')
|
||||
const railLeft = useSafeAreaInsets().left
|
||||
const hideSidebar = useCallback(() => setSidebarOpen(false), [])
|
||||
const revealSidebar = useCallback(() => setSidebarOpen(true), [])
|
||||
const [hostAreaServing, setHostAreaServing] = useState(false)
|
||||
const pageOwnsHostArea = usePageOwnsHostArea()
|
||||
useReportedPageLocation()
|
||||
// One owner: the page only with its init fact; natively all but a host route its page is serving.
|
||||
const sidebarDrawnHere =
|
||||
Platform.OS === 'web' ? pageOwnsHostArea : !(pathname === `/h/${hostId}` && hostAreaServing)
|
||||
const showSidebar = isWideLayout && !!hostId && sidebarDrawnHere
|
||||
const detailHasContent = !!hostId && pathname !== `/h/${hostId}`
|
||||
// Only the page's session header can reveal it again; a native sidebar beside a page detail could never come back.
|
||||
const canCollapseSidebar = Platform.OS === 'web' && showSidebar && detailHasContent
|
||||
// Book posture: the sidebar takes the half before the fold and the detail the half after it, so
|
||||
// the terminal never runs across the crease.
|
||||
const foldSplit = useFoldSplit()
|
||||
const foldSidebarWidth =
|
||||
showSidebar &&
|
||||
foldSplit?.axis === 'vertical' &&
|
||||
foldSplit.start >= HOST_SIDEBAR_MIN_WIDTH &&
|
||||
foldSplit.size - foldSplit.end >= MIN_DETAIL_WIDTH
|
||||
? foldSplit.start
|
||||
: null
|
||||
const foldGap = foldSidebarWidth === null || !foldSplit ? 0 : foldSplit.end - foldSplit.start
|
||||
const canCollapseSidebar = showSidebar && detailHasContent && foldSidebarWidth === null
|
||||
|
||||
// Base host detail is a placeholder; hiding its sidebar would leave nothing useful.
|
||||
useEffect(() => {
|
||||
@@ -122,22 +122,26 @@ export default function HostGroupLayout() {
|
||||
return (
|
||||
<HostProtocolGate hostId={hostId} transparentWhilePending={overlayRoute}>
|
||||
<View style={[styles.row, overlayRoute && styles.transparent]}>
|
||||
{showSidebar && sidebarOpen ? (
|
||||
<View style={[styles.sidebar, { width: sidebarWidth, paddingLeft: railLeft }]}>
|
||||
{showSidebar && (sidebarOpen || foldSidebarWidth !== null) ? (
|
||||
<View style={[styles.sidebar, { width: foldSidebarWidth ?? sidebarWidth }]}>
|
||||
<HostScreen
|
||||
embedded
|
||||
hostId={hostId}
|
||||
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
|
||||
actionsInRail={!detailHasContent}
|
||||
/>
|
||||
{/* Edge drag handle captures Android gestures above the list. */}
|
||||
<View style={styles.resizeHandle} {...resizer.panHandlers} />
|
||||
{foldSidebarWidth === null ? (
|
||||
<View style={styles.resizeHandle} {...resizer.panHandlers} />
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{foldGap > 0 ? <View style={{ width: foldGap }} /> : null}
|
||||
<View style={styles.detail}>
|
||||
<HostAreaServingContext.Provider value={setHostAreaServing}>
|
||||
<HostSidebarRevealContext.Provider
|
||||
value={showSidebar && !sidebarOpen ? revealSidebar : null}
|
||||
value={
|
||||
showSidebar && !sidebarOpen && foldSidebarWidth === null ? revealSidebar : null
|
||||
}
|
||||
>
|
||||
<HostStack animation={showSidebar ? 'none' : 'default'} />
|
||||
</HostSidebarRevealContext.Provider>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"platforms": ["apple"],
|
||||
"apple": {
|
||||
"modules": ["OrcaReservedRegionsModule"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'OrcaReservedRegions'
|
||||
s.version = '0.0.1'
|
||||
s.summary = 'Reports the window regions a fold or camera reserves on iPhone Duo'
|
||||
s.description = s.summary
|
||||
s.license = { :type => 'MIT' }
|
||||
s.author = 'Orca'
|
||||
s.homepage = 'https://onorca.dev'
|
||||
s.source = { :git => 'https://github.com/stablyai/orca.git' }
|
||||
s.platforms = { :ios => '15.1' }
|
||||
s.swift_version = '5.9'
|
||||
s.static_framework = true
|
||||
s.dependency 'ExpoModulesCore'
|
||||
s.source_files = '**/*.swift'
|
||||
end
|
||||
@@ -0,0 +1,192 @@
|
||||
import ExpoModulesCore
|
||||
import UIKit
|
||||
|
||||
// Why: iPhone Duo reserves part of the window for its fold (when partially folded) and its
|
||||
// camera, and neither shows up in the safe area. React Native has no view of either, so the
|
||||
// window's active regions are reported here in window points, the root view's coordinates.
|
||||
public class OrcaReservedRegionsModule: Module {
|
||||
private var probe: UIView?
|
||||
private var keyWindowObserver: NSObjectProtocol?
|
||||
|
||||
public func definition() -> ModuleDefinition {
|
||||
Name("OrcaReservedRegions")
|
||||
|
||||
Events("onChange")
|
||||
|
||||
OnStartObserving {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.installProbe()
|
||||
// A reconnected scene brings a new window; the probe follows it.
|
||||
self.keyWindowObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIWindow.didBecomeKeyNotification, object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.installProbe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OnStopObserving {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
if let keyWindowObserver = self.keyWindowObserver {
|
||||
NotificationCenter.default.removeObserver(keyWindowObserver)
|
||||
}
|
||||
self.keyWindowObserver = nil
|
||||
self.probe?.removeFromSuperview()
|
||||
self.probe = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func installProbe() {
|
||||
guard let window = UIApplication.shared.delegate?.window ?? nil, probe?.window !== window else {
|
||||
return
|
||||
}
|
||||
probe?.removeFromSuperview()
|
||||
probe = nil
|
||||
#if canImport(UIKit, _version: 9127.0.85)
|
||||
// 9127.0.85 is UIKit in the iOS 27.1 SDK, the first with reserved regions; older Xcodes skip this.
|
||||
if #available(iOS 27.1, *) {
|
||||
let probe = ReservedRegionsProbe(frame: window.bounds)
|
||||
probe.onChange = { [weak self] payload in
|
||||
self?.sendEvent("onChange", payload)
|
||||
}
|
||||
window.insertSubview(probe, at: 0)
|
||||
self.probe = probe
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit, _version: 9127.0.85)
|
||||
/// Invisible full-window view: it is laid out on every window resize and safe-area change, and
|
||||
/// its hinge interaction lays it out again when the device folds without changing size.
|
||||
@available(iOS 27.1, *)
|
||||
private final class ReservedRegionsProbe: UIView {
|
||||
var onChange: (([String: Any]) -> Void)?
|
||||
private var reported: ReservedRegionsSnapshot?
|
||||
private weak var keyboardView: UIView?
|
||||
private let keyboardFollower = KeyboardGeometryFollower()
|
||||
private var keyboardConstraints: [NSLayoutConstraint] = []
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
isUserInteractionEnabled = false
|
||||
isAccessibilityElement = false
|
||||
keyboardFollower.translatesAutoresizingMaskIntoConstraints = false
|
||||
keyboardFollower.onLayout = { [weak self] in self?.setNeedsLayout() }
|
||||
addSubview(keyboardFollower)
|
||||
addInteraction(
|
||||
UIHingeInteraction { [weak self] _, _ in
|
||||
self?.setNeedsLayout()
|
||||
})
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) is not supported")
|
||||
}
|
||||
|
||||
override func didMoveToWindow() {
|
||||
super.didMoveToWindow()
|
||||
NSLayoutConstraint.deactivate(keyboardConstraints)
|
||||
keyboardConstraints = []
|
||||
keyboardView = nil
|
||||
guard let window, let root = window.rootViewController?.view, root.window === window else {
|
||||
return
|
||||
}
|
||||
keyboardView = root
|
||||
let guide = root.keyboardLayoutGuide
|
||||
guide.usesBottomSafeArea = false
|
||||
keyboardConstraints = [
|
||||
keyboardFollower.topAnchor.constraint(equalTo: guide.topAnchor),
|
||||
keyboardFollower.bottomAnchor.constraint(equalTo: guide.bottomAnchor),
|
||||
keyboardFollower.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
|
||||
keyboardFollower.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
|
||||
]
|
||||
NSLayoutConstraint.activate(keyboardConstraints)
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func safeAreaInsetsDidChange() {
|
||||
super.safeAreaInsetsDidChange()
|
||||
setNeedsLayout()
|
||||
}
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard let window else { return }
|
||||
let keyboardInset: CGFloat?
|
||||
if let keyboardView, keyboardView.window === window {
|
||||
let frame = keyboardView.convert(keyboardView.keyboardLayoutGuide.layoutFrame, to: window)
|
||||
let overlap = frame.intersection(window.bounds)
|
||||
keyboardInset = overlap.isNull ? 0 : overlap.height
|
||||
} else {
|
||||
keyboardInset = nil
|
||||
}
|
||||
let snapshot = ReservedRegionsSnapshot(
|
||||
divisions: window.reservedRegions(kind: .division).map(\.frame),
|
||||
hasFoldDivision: !window.reservedRegions(kind: .division, options: .includeInactive).isEmpty,
|
||||
occlusions: window.reservedRegions(kind: .occlusion).map(\.frame),
|
||||
windowBounds: window.bounds,
|
||||
windowSafeAreaInsets: window.safeAreaInsets,
|
||||
keyboardInset: keyboardInset
|
||||
)
|
||||
// The first layout always reports, so a listener starts from the real state.
|
||||
guard snapshot != reported else { return }
|
||||
reported = snapshot
|
||||
onChange?(snapshot.payload)
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 27.1, *)
|
||||
private final class KeyboardGeometryFollower: UIView {
|
||||
var onLayout: (() -> Void)?
|
||||
private var reportedFrame: CGRect?
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
guard frame != reportedFrame else { return }
|
||||
reportedFrame = frame
|
||||
onLayout?()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReservedRegionsSnapshot: Equatable {
|
||||
let divisions: [CGRect]
|
||||
let hasFoldDivision: Bool
|
||||
let occlusions: [CGRect]
|
||||
let windowBounds: CGRect
|
||||
let windowSafeAreaInsets: UIEdgeInsets
|
||||
let keyboardInset: CGFloat?
|
||||
|
||||
var payload: [String: Any] {
|
||||
var result: [String: Any] = [
|
||||
"divisions": divisions.map(Self.rectPayload),
|
||||
"hasFoldDivision": hasFoldDivision,
|
||||
"occlusions": occlusions.map(Self.rectPayload),
|
||||
"windowSafeArea": [
|
||||
"bounds": Self.rectPayload(windowBounds),
|
||||
"insets": [
|
||||
"top": Double(windowSafeAreaInsets.top),
|
||||
"right": Double(windowSafeAreaInsets.right),
|
||||
"bottom": Double(windowSafeAreaInsets.bottom),
|
||||
"left": Double(windowSafeAreaInsets.left),
|
||||
],
|
||||
],
|
||||
]
|
||||
if let keyboardInset {
|
||||
result["keyboardInset"] = Double(keyboardInset)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func rectPayload(_ rect: CGRect) -> [String: Double] {
|
||||
[
|
||||
"x": Double(rect.minX), "y": Double(rect.minY), "width": Double(rect.width),
|
||||
"height": Double(rect.height),
|
||||
]
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "orca-reserved-regions",
|
||||
"version": "0.0.1",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core'
|
||||
|
||||
/** A rect in window points, the root view's coordinates. */
|
||||
export type ReservedRect = { x: number; y: number; width: number; height: number }
|
||||
|
||||
export type WindowSafeArea = {
|
||||
bounds: ReservedRect
|
||||
insets: { top: number; right: number; bottom: number; left: number }
|
||||
}
|
||||
|
||||
/** The window's active reserved regions: a partially folded iPhone Duo's fold, and its camera. */
|
||||
export type ReservedRegions = {
|
||||
divisions: ReservedRect[]
|
||||
hasFoldDivision?: boolean
|
||||
occlusions: ReservedRect[]
|
||||
keyboardInset?: number
|
||||
windowSafeArea?: WindowSafeArea
|
||||
}
|
||||
|
||||
type ReservedRegionsEvents = {
|
||||
/** Sent once the module starts observing, then whenever the active regions change. */
|
||||
onChange: (regions: ReservedRegions) => void
|
||||
}
|
||||
|
||||
declare class OrcaReservedRegionsModule extends NativeModule<ReservedRegionsEvents> {}
|
||||
|
||||
/**
|
||||
* Null on Android, the web and in a binary built before the module existed. A binary built with
|
||||
* an SDK older than iOS 27.1, or running on an older iOS, never sends `onChange`.
|
||||
*/
|
||||
export const OrcaReservedRegions =
|
||||
requireOptionalNativeModule<OrcaReservedRegionsModule>('OrcaReservedRegions')
|
||||
@@ -10,6 +10,16 @@ public class OrcaWindowControlsInsetSubscriber: ExpoAppDelegateSubscriber {
|
||||
if let window = application.delegate?.window ?? nil {
|
||||
WindowControlsInsetProbe.install(in: window)
|
||||
}
|
||||
// Under the UIScene lifecycle the scene delegate creates the window after launch.
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: UIWindow.didBecomeKeyNotification, object: nil, queue: .main
|
||||
) { notification in
|
||||
if let window = notification.object as? UIWindow,
|
||||
window === UIApplication.shared.delegate?.window ?? nil
|
||||
{
|
||||
WindowControlsInsetProbe.install(in: window)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -19,6 +29,7 @@ private final class WindowControlsInsetProbe: UIView {
|
||||
private var rootObservation: NSKeyValueObservation?
|
||||
|
||||
static func install(in window: UIWindow) {
|
||||
guard !window.subviews.contains(where: { $0 is WindowControlsInsetProbe }) else { return }
|
||||
let probe = WindowControlsInsetProbe(frame: window.bounds)
|
||||
probe.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
probe.isUserInteractionEnabled = false
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
diff --git a/ios/LinkPreview/LinkPreviewNativeActionView.swift b/ios/LinkPreview/LinkPreviewNativeActionView.swift
|
||||
index 3884b7844b99484f705632100134a16547e0bf0f..af749936ffc60f54f560fcb84bd6ad4067c746a4 100644
|
||||
--- a/ios/LinkPreview/LinkPreviewNativeActionView.swift
|
||||
+++ b/ios/LinkPreview/LinkPreviewNativeActionView.swift
|
||||
@@ -137,7 +137,8 @@ class LinkPreviewNativeActionView: RouterViewWithLogger, LinkPreviewMenuUpdatabl
|
||||
baseUiAction.attributes = attributes
|
||||
baseUiAction.state = isOn == true ? .on : .off
|
||||
|
||||
- if let subtitle = subtitle {
|
||||
+ // Orca: the iOS 27.1 SDK marks UIAction.subtitle iOS 16+, which fails the iOS 15.1 build.
|
||||
+ if let subtitle = subtitle, #available(iOS 16.0, *) {
|
||||
baseUiAction.subtitle = subtitle
|
||||
}
|
||||
if let label = discoverabilityLabel {
|
||||
@@ -0,0 +1,33 @@
|
||||
const { withPodfile } = require('expo/config-plugins')
|
||||
|
||||
// Why: Xcode 27 rejects deployment targets below iOS 15.0, and some pods (and their
|
||||
// resource-bundle targets) still declare 9.0–13.4, which fails the build. The iOS 27 SDK
|
||||
// is what lets the app resize on iPhone Duo, so raise those targets to the app's own.
|
||||
const DEPLOYMENT_TARGET_FLOOR = '15.1'
|
||||
const MARKER = '# orca: pod deployment target floor'
|
||||
const POST_INSTALL = 'post_install do |installer|'
|
||||
|
||||
const FLOOR_SNIPPET = `
|
||||
${MARKER}
|
||||
installer.pods_project.targets.each do |target|
|
||||
target.build_configurations.each do |build_config|
|
||||
target_version = build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET']
|
||||
if target_version && Gem::Version.new(target_version) < Gem::Version.new('${DEPLOYMENT_TARGET_FLOOR}')
|
||||
build_config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '${DEPLOYMENT_TARGET_FLOOR}'
|
||||
end
|
||||
end
|
||||
end`
|
||||
|
||||
module.exports = function withIosPodDeploymentTargetFloor(config) {
|
||||
return withPodfile(config, (cfg) => {
|
||||
const podfile = cfg.modResults.contents
|
||||
if (podfile.includes(MARKER)) {
|
||||
return cfg
|
||||
}
|
||||
if (!podfile.includes(POST_INSTALL)) {
|
||||
throw new Error('ios-pod-deployment-target-floor: Podfile has no post_install hook')
|
||||
}
|
||||
cfg.modResults.contents = podfile.replace(POST_INSTALL, `${POST_INSTALL}${FLOOR_SNIPPET}`)
|
||||
return cfg
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const {
|
||||
IOSConfig,
|
||||
withAppDelegate,
|
||||
withDangerousMod,
|
||||
withInfoPlist,
|
||||
withXcodeProject
|
||||
} = require('expo/config-plugins')
|
||||
|
||||
// Why: UIKit stops an app built with the iOS 27 SDK at launch unless it adopts the UIScene
|
||||
// lifecycle (TN3187), and Expo SDK 55's AppDelegate template still creates the window itself.
|
||||
// The iOS 27 SDK is what lets the app resize on iPhone Duo, so the window moves into a scene
|
||||
// delegate here. Drop this plugin once Expo's template adopts scenes (SDK 58).
|
||||
const MARKER = 'orca: UIScene lifecycle'
|
||||
|
||||
const SCENE_MANIFEST = {
|
||||
UIApplicationSupportsMultipleScenes: false,
|
||||
UISceneConfigurations: {
|
||||
UIWindowSceneSessionRoleApplication: [
|
||||
{
|
||||
UISceneConfigurationName: 'Default Configuration',
|
||||
UISceneDelegateClassName: '$(PRODUCT_MODULE_NAME).SceneDelegate'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const FACTORY_PROPERTY = ' var reactNativeFactory: RCTReactNativeFactory?\n'
|
||||
const WINDOW_BLOCK =
|
||||
/#if os\(iOS\) \|\| os\(tvOS\)\s*\n\s*window = UIWindow\(frame: UIScreen\.main\.bounds\)\s*\n\s*factory\.startReactNative\(\s*withModuleName: "main",\s*in: window,\s*launchOptions: launchOptions\)\s*\n#endif\n/
|
||||
|
||||
function patchAppDelegate(contents) {
|
||||
if (contents.includes(MARKER)) {
|
||||
return contents
|
||||
}
|
||||
// A changed template must fail prebuild rather than ship an app that cannot launch.
|
||||
if (!contents.includes(FACTORY_PROPERTY) || !WINDOW_BLOCK.test(contents)) {
|
||||
throw new Error(
|
||||
"ios-scene-lifecycle: Expo's AppDelegate template changed; update the plugin before building."
|
||||
)
|
||||
}
|
||||
return contents
|
||||
.replace(
|
||||
FACTORY_PROPERTY,
|
||||
`${FACTORY_PROPERTY} // ${MARKER}: SceneDelegate starts React Native with these.\n var launchOptions: [UIApplication.LaunchOptionsKey: Any]?\n`
|
||||
)
|
||||
.replace(
|
||||
WINDOW_BLOCK,
|
||||
` // ${MARKER}: SceneDelegate creates the window.\n self.launchOptions = launchOptions\n`
|
||||
)
|
||||
}
|
||||
|
||||
const SCENE_DELEGATE_SWIFT = `import React
|
||||
import UIKit
|
||||
|
||||
// ${MARKER}, generated by plugins/ios-scene-lifecycle.js.
|
||||
// Under scenes UIKit stops calling the AppDelegate URL callbacks, where RCTLinkingManager and
|
||||
// Expo's subscribers listen, so every URL and user activity is forwarded to them.
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
var window: UIWindow?
|
||||
|
||||
private var appDelegate: AppDelegate? {
|
||||
UIApplication.shared.delegate as? AppDelegate
|
||||
}
|
||||
|
||||
func scene(
|
||||
_ scene: UIScene,
|
||||
willConnectTo session: UISceneSession,
|
||||
options connectionOptions: UIScene.ConnectionOptions
|
||||
) {
|
||||
guard let windowScene = scene as? UIWindowScene, let appDelegate,
|
||||
let factory = appDelegate.reactNativeFactory
|
||||
else { return }
|
||||
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
self.window = window
|
||||
// A scene iOS dropped in the background and reconnects keeps the running app, not a fresh one.
|
||||
let runningRoot = appDelegate.window?.rootViewController
|
||||
appDelegate.window = window
|
||||
|
||||
let launchURL = connectionOptions.urlContexts.first
|
||||
// Universal links are the only activity the app handles.
|
||||
let launchActivity = connectionOptions.userActivities.first {
|
||||
$0.activityType == NSUserActivityTypeBrowsingWeb
|
||||
}
|
||||
|
||||
if let runningRoot {
|
||||
window.rootViewController = runningRoot
|
||||
window.makeKeyAndVisible()
|
||||
} else {
|
||||
// A cold-start URL arrives here rather than in the launch options getInitialURL reads.
|
||||
var launchOptions = appDelegate.launchOptions ?? [:]
|
||||
if let launchURL {
|
||||
launchOptions[.url] = launchURL.url
|
||||
if let source = launchURL.options.sourceApplication {
|
||||
launchOptions[.sourceApplication] = source
|
||||
}
|
||||
}
|
||||
if let launchActivity {
|
||||
launchOptions[.userActivityDictionary] = [
|
||||
UIApplication.LaunchOptionsKey.userActivityType.rawValue: launchActivity.activityType,
|
||||
"UIApplicationLaunchOptionsUserActivityKey": launchActivity,
|
||||
]
|
||||
}
|
||||
factory.startReactNative(withModuleName: "main", in: window, launchOptions: launchOptions)
|
||||
}
|
||||
|
||||
if let launchURL {
|
||||
forward(launchURL)
|
||||
}
|
||||
if let launchActivity {
|
||||
_ = appDelegate.application(UIApplication.shared, continue: launchActivity) { _ in }
|
||||
}
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||
for context in URLContexts {
|
||||
forward(context)
|
||||
}
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
_ = appDelegate?.application(UIApplication.shared, continue: userActivity) { _ in }
|
||||
}
|
||||
|
||||
private func forward(_ context: UIOpenURLContext) {
|
||||
var options: [UIApplication.OpenURLOptionsKey: Any] = [
|
||||
.openInPlace: context.options.openInPlace
|
||||
]
|
||||
if let source = context.options.sourceApplication {
|
||||
options[.sourceApplication] = source
|
||||
}
|
||||
if let annotation = context.options.annotation {
|
||||
options[.annotation] = annotation
|
||||
}
|
||||
_ = appDelegate?.application(UIApplication.shared, open: context.url, options: options)
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
module.exports = function withIosSceneLifecycle(config) {
|
||||
config = withInfoPlist(config, (cfg) => {
|
||||
cfg.modResults.UIApplicationSceneManifest = SCENE_MANIFEST
|
||||
return cfg
|
||||
})
|
||||
|
||||
config = withAppDelegate(config, (cfg) => {
|
||||
if (cfg.modResults.language !== 'swift') {
|
||||
throw new Error(
|
||||
`ios-scene-lifecycle: expected a Swift AppDelegate, got ${cfg.modResults.language}`
|
||||
)
|
||||
}
|
||||
cfg.modResults.contents = patchAppDelegate(cfg.modResults.contents)
|
||||
return cfg
|
||||
})
|
||||
|
||||
config = withDangerousMod(config, [
|
||||
'ios',
|
||||
(cfg) => {
|
||||
const { platformProjectRoot, projectName } = cfg.modRequest
|
||||
fs.writeFileSync(
|
||||
path.join(platformProjectRoot, projectName, 'SceneDelegate.swift'),
|
||||
SCENE_DELEGATE_SWIFT
|
||||
)
|
||||
return cfg
|
||||
}
|
||||
])
|
||||
|
||||
return withXcodeProject(config, (cfg) => {
|
||||
const filepath = `${cfg.modRequest.projectName}/SceneDelegate.swift`
|
||||
if (!cfg.modResults.hasFile(filepath)) {
|
||||
IOSConfig.XcodeUtils.addBuildSourceFileToGroup({
|
||||
filepath,
|
||||
groupName: cfg.modRequest.projectName,
|
||||
project: cfg.modResults
|
||||
})
|
||||
}
|
||||
return cfg
|
||||
})
|
||||
}
|
||||
Generated
+5
-4
@@ -176,6 +176,7 @@ patchedDependencies:
|
||||
'@xterm/addon-webgl@0.20.0-beta.300': 5349718f2c19d9319a81552580bf9618ded9b00f3707a5c6cdbbae1910849cd9
|
||||
braces@3.0.3: c07bb14cec04bc891e1fe529720444cc9729a25117f9aaf24253cfe3132a34fd
|
||||
expo-notifications@55.0.27: ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0
|
||||
expo-router@55.0.18: 5cdc178be0ec617ebb8dec85eebe4effeca13a6551b87fcb7bcd20ec0d2a313d
|
||||
node-forge@1.4.0: 2798686e4488058ad81c3b8e32eb763c0feff21f4c717ce4d13b6ef2432cd5b8
|
||||
query-string@9.5.1: 8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b
|
||||
react-native-webview@13.16.2: 55b7bce35c78ee1a50c4b99ad65b52982f3cfa73da6f61d0853c4d03219eec06
|
||||
@@ -264,7 +265,7 @@ importers:
|
||||
version: 55.0.27(patch_hash=ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0)(expo@55.0.31)(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)(supports-color@8.1.1)(typescript@7.0.2)
|
||||
expo-router:
|
||||
specifier: ^55.0.18
|
||||
version: 55.0.18(589555b6d8c5573172d9947cbca71959)
|
||||
version: 55.0.18(patch_hash=5cdc178be0ec617ebb8dec85eebe4effeca13a6551b87fcb7bcd20ec0d2a313d)(589555b6d8c5573172d9947cbca71959)
|
||||
expo-secure-store:
|
||||
specifier: ^55.0.18
|
||||
version: 55.0.18(expo@55.0.31)
|
||||
@@ -8918,7 +8919,7 @@ snapshots:
|
||||
ws: 8.22.0
|
||||
zod: 3.25.76(patch_hash=8f57d8e7910df51ec3c40c412b4f8fb3d01fd65e1889c057aae0e4a15483bcec)
|
||||
optionalDependencies:
|
||||
expo-router: 55.0.18(589555b6d8c5573172d9947cbca71959)
|
||||
expo-router: 55.0.18(patch_hash=5cdc178be0ec617ebb8dec85eebe4effeca13a6551b87fcb7bcd20ec0d2a313d)(589555b6d8c5573172d9947cbca71959)
|
||||
react-native: 0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- '@expo/dom-webview'
|
||||
@@ -9252,7 +9253,7 @@ snapshots:
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
'@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.31)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)
|
||||
expo-router: 55.0.18(589555b6d8c5573172d9947cbca71959)
|
||||
expo-router: 55.0.18(patch_hash=5cdc178be0ec617ebb8dec85eebe4effeca13a6551b87fcb7bcd20ec0d2a313d)(589555b6d8c5573172d9947cbca71959)
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -12396,7 +12397,7 @@ snapshots:
|
||||
- supports-color
|
||||
- typescript
|
||||
|
||||
expo-router@55.0.18(589555b6d8c5573172d9947cbca71959):
|
||||
expo-router@55.0.18(patch_hash=5cdc178be0ec617ebb8dec85eebe4effeca13a6551b87fcb7bcd20ec0d2a313d)(589555b6d8c5573172d9947cbca71959):
|
||||
dependencies:
|
||||
'@expo/log-box': 55.0.13(@expo/dom-webview@55.0.6)(expo@55.0.31)(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)
|
||||
'@expo/metro-runtime': 55.0.12(@expo/dom-webview@55.0.6)(expo@55.0.31)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=22bc31f0a057b1f96d9949b38a7ccde81225f617c0f243820dc8a455c91debfb)(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.85.2(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.2.18)(react@19.2.8)(supports-color@8.1.1))(react@19.2.8)
|
||||
|
||||
@@ -29,3 +29,4 @@ patchedDependencies:
|
||||
braces@3.0.3: patches/braces@3.0.3.patch
|
||||
node-forge@1.4.0: patches/node-forge@1.4.0.patch
|
||||
'@expo/cli@55.0.36': patches/@expo__cli@55.0.36.patch
|
||||
expo-router@55.0.18: patches/expo-router@55.0.18.patch
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
AppState,
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Switch,
|
||||
type AppStateStatus
|
||||
} from 'react-native'
|
||||
import { AppState, View, Text, Pressable, Switch, type AppStateStatus } from 'react-native'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
import { ChevronRight, X } from 'lucide-react-native'
|
||||
import type Animated from 'react-native-reanimated'
|
||||
import type { AnimatedRef, SharedValue } from 'react-native-reanimated'
|
||||
import { useDirectionPadAvailable } from '../layout/use-shell-side-rail'
|
||||
import { CustomKeyModal } from './CustomKeyModal'
|
||||
import { loadCustomKeys, saveCustomKeys, type CustomKey } from '../terminal/terminal-custom-keys'
|
||||
import { DragReorderList } from './DragReorderList'
|
||||
import { useSerializedStoredState } from '../storage/use-serialized-stored-state'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './terminal-shortcut-settings-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
import {
|
||||
@@ -29,8 +22,7 @@ import {
|
||||
loadTerminalAccessoryLayout,
|
||||
reorderTerminalAccessoryBuiltInIds,
|
||||
saveTerminalAccessoryLayout,
|
||||
setTerminalAccessoryBuiltInVisible,
|
||||
type TerminalAccessoryLayout
|
||||
setTerminalAccessoryBuiltInVisible
|
||||
} from '../terminal/terminal-accessory-layout'
|
||||
|
||||
// Absolutely positioned DragReorderList rows require one fixed height per section.
|
||||
@@ -46,6 +38,7 @@ function ShortcutBarRow({
|
||||
onToggle: (visible: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
return (
|
||||
<View style={styles.reorderRowContent}>
|
||||
<View style={styles.keycap}>
|
||||
@@ -55,6 +48,13 @@ function ShortcutBarRow({
|
||||
<Text style={styles.rowLabel}>{shortcutKey.accessibilityLabel ?? shortcutKey.label}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
accessibilityLabel={t(
|
||||
'components.terminalShortcutSettings.showShortcut',
|
||||
'Show {{label}} shortcut',
|
||||
{
|
||||
label: shortcutKey.accessibilityLabel ?? shortcutKey.label
|
||||
}
|
||||
)}
|
||||
value={visible}
|
||||
onValueChange={onToggle}
|
||||
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
||||
@@ -64,11 +64,6 @@ function ShortcutBarRow({
|
||||
)
|
||||
}
|
||||
|
||||
async function loadShortcutLayout(): Promise<TerminalAccessoryLayout> {
|
||||
const { orderedBuiltInIds, visibleBuiltInIds } = await loadTerminalAccessoryLayout()
|
||||
return { orderedBuiltInIds, visibleBuiltInIds }
|
||||
}
|
||||
|
||||
type Props = {
|
||||
scrollRef: AnimatedRef<Animated.ScrollView>
|
||||
scrollOffsetY: SharedValue<number>
|
||||
@@ -84,6 +79,9 @@ export function TerminalShortcutSettings({
|
||||
}: Props): React.JSX.Element {
|
||||
useMobileColorScheme()
|
||||
const t = useMobileTranslation()
|
||||
const hasPad = useDirectionPadAvailable()
|
||||
const defaultLayout = useCallback(() => getDefaultTerminalAccessoryLayout(hasPad), [hasPad])
|
||||
const loadShortcutLayout = useCallback(() => loadTerminalAccessoryLayout(hasPad), [hasPad])
|
||||
const {
|
||||
value: customKeys,
|
||||
setValue: setCustomKeys,
|
||||
@@ -97,11 +95,7 @@ export function TerminalShortcutSettings({
|
||||
setValue: setShortcutLayout,
|
||||
persist: persistLayout,
|
||||
refresh: refreshShortcutLayout
|
||||
} = useSerializedStoredState(
|
||||
getDefaultTerminalAccessoryLayout,
|
||||
loadShortcutLayout,
|
||||
saveTerminalAccessoryLayout
|
||||
)
|
||||
} = useSerializedStoredState(defaultLayout, loadShortcutLayout, saveTerminalAccessoryLayout)
|
||||
|
||||
const handleDeleteCustomKey = useCallback(
|
||||
(key: CustomKey) => {
|
||||
@@ -154,10 +148,10 @@ export function TerminalShortcutSettings({
|
||||
)
|
||||
|
||||
const resetBuiltInKeys = useCallback(() => {
|
||||
const next = getDefaultTerminalAccessoryLayout()
|
||||
const next = defaultLayout()
|
||||
setShortcutLayout(next)
|
||||
persistLayout(next)
|
||||
}, [persistLayout])
|
||||
}, [defaultLayout, persistLayout])
|
||||
|
||||
const reorderCustomKeys = useCallback(
|
||||
(orderedKeys: string[]) => {
|
||||
@@ -177,10 +171,6 @@ export function TerminalShortcutSettings({
|
||||
[persistCustomKeys]
|
||||
)
|
||||
|
||||
const visibleBuiltInSet = useMemo(
|
||||
() => new Set(shortcutLayout.visibleBuiltInIds),
|
||||
[shortcutLayout.visibleBuiltInIds]
|
||||
)
|
||||
const orderedAccessoryKeys = useMemo(() => {
|
||||
const byId = new Map(TERMINAL_ACCESSORY_KEYS.map((key) => [key.id, key]))
|
||||
return shortcutLayout.orderedBuiltInIds.flatMap((id) => {
|
||||
@@ -191,6 +181,44 @@ export function TerminalShortcutSettings({
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasPad ? (
|
||||
<>
|
||||
<Text style={[styles.groupHeading, styles.groupTopGap]}>
|
||||
{t('components.terminalShortcutSettings.directionPad', 'DIRECTION PAD')}
|
||||
</Text>
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<View style={styles.row}>
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>
|
||||
{t('components.terminalShortcutSettings.showDirectionPad', 'Show D-pad')}
|
||||
</Text>
|
||||
<Text style={styles.rowSublabel}>
|
||||
{t(
|
||||
'components.terminalShortcutSettings.showDirectionPadDescription',
|
||||
'Pin the arrow pad in the side rail. Without it, the rail lists the arrow keys.'
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
accessibilityLabel={t(
|
||||
'components.terminalShortcutSettings.showDirectionPad',
|
||||
'Show D-pad'
|
||||
)}
|
||||
value={shortcutLayout.directionPadEnabled !== false}
|
||||
onValueChange={(enabled) =>
|
||||
setShortcutLayout((current) => {
|
||||
const next = { ...current, directionPadEnabled: enabled }
|
||||
persistLayout(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
<Text style={[styles.groupHeading, styles.groupTopGap]}>
|
||||
{t('components.terminalShortcutSettings.moreKeys', 'MORE KEYS')}
|
||||
</Text>
|
||||
@@ -213,7 +241,7 @@ export function TerminalShortcutSettings({
|
||||
renderRow={(shortcutKey) => (
|
||||
<ShortcutBarRow
|
||||
shortcutKey={shortcutKey}
|
||||
visible={visibleBuiltInSet.has(shortcutKey.id)}
|
||||
visible={shortcutLayout.visibleBuiltInIds.includes(shortcutKey.id)}
|
||||
onToggle={(visible) => toggleBuiltInKey(shortcutKey.id, visible)}
|
||||
/>
|
||||
)}
|
||||
@@ -270,6 +298,27 @@ export function TerminalShortcutSettings({
|
||||
{key.bytes.replace(/\r/g, ' ↵')}
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
accessibilityLabel={t(
|
||||
'components.terminalShortcutSettings.showShortcut',
|
||||
'Show {{label}} shortcut',
|
||||
{
|
||||
label: key.label
|
||||
}
|
||||
)}
|
||||
value={key.visible !== false}
|
||||
onValueChange={(visible) =>
|
||||
setCustomKeys((current) => {
|
||||
const next = current.map((item) =>
|
||||
item.id === key.id ? { ...item, visible } : item
|
||||
)
|
||||
persistCustomKeys(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.deleteButton,
|
||||
@@ -317,102 +366,3 @@ export function TerminalShortcutSettings({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = themedStyleSheet(() => ({
|
||||
groupHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
groupTopGap: {
|
||||
marginTop: spacing.xl
|
||||
},
|
||||
groupDescription: {
|
||||
fontSize: typography.bodySize - 1,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
sectionTopGap: {
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
// Reorder list supplies fixed height/trailing grip; pad content only on left.
|
||||
reorderRowContent: {
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingLeft: spacing.md + 2
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowSublabel: {
|
||||
fontSize: typography.bodySize - 2,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2
|
||||
},
|
||||
keycap: {
|
||||
minWidth: 62,
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
keycapText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
padding: spacing.md
|
||||
},
|
||||
deleteButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)'
|
||||
},
|
||||
deleteButtonPressed: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.2)'
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -224,6 +224,7 @@ vi.mock('lucide-react-native', () => ({
|
||||
}))
|
||||
vi.mock('expo-router', () => ({ useFocusEffect: () => {} }))
|
||||
vi.mock('./CustomKeyModal', () => ({ CustomKeyModal: () => null }))
|
||||
vi.mock('../layout/use-shell-side-rail', () => ({ useDirectionPadAvailable: () => false }))
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: { getItem: async () => null, setItem: async () => {} }
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createElement, useEffect, type ReactNode } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadTerminalAccessoryLayout } from '../terminal/terminal-accessory-layout'
|
||||
import type { CustomKey } from '../terminal/terminal-custom-keys'
|
||||
|
||||
const CUSTOM_KEYS = 'orca:custom-accessory-keys'
|
||||
@@ -34,7 +35,40 @@ const hosts = vi.hoisted(() => {
|
||||
}
|
||||
return { View: make('View'), Text: make('Text'), Pressable: make('Pressable') }
|
||||
})
|
||||
const geometry = vi.hoisted(() => ({ keyboard: 0, duo: true, hasFoldDivision: false }))
|
||||
vi.mock('../platform/keyboard-occlusion', () => ({ useKeyboardOcclusion: () => geometry.keyboard }))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({
|
||||
top: geometry.hasFoldDivision ? 82 : 0,
|
||||
right: geometry.duo ? 84 : 0,
|
||||
bottom: 34,
|
||||
left: 0
|
||||
})
|
||||
}))
|
||||
vi.mock('../layout/reserved-regions', () => ({
|
||||
useReservedRegions: () => ({
|
||||
occlusions: geometry.duo
|
||||
? [{ x: 867, y: 0, width: 84, height: 124 }]
|
||||
: geometry.hasFoldDivision
|
||||
? [{ x: 535, y: 0, width: 134, height: 82 }]
|
||||
: [],
|
||||
divisions: [],
|
||||
hasFoldDivision: geometry.hasFoldDivision,
|
||||
windowSafeArea: null
|
||||
})
|
||||
}))
|
||||
import { SideRailProvider, useSideRailStrip } from '../layout/side-rail'
|
||||
import { usePageFoldDivision } from '../mobile-web-shell/page-fold-division'
|
||||
let drawnHeight: number | null = null
|
||||
let pagePadAvailable: boolean | undefined
|
||||
function GeometryProbe() {
|
||||
drawnHeight = useSideRailStrip()?.height ?? null
|
||||
pagePadAvailable = usePageFoldDivision().geometry.directionPadAvailable
|
||||
return null
|
||||
}
|
||||
vi.mock('react-native', () => ({
|
||||
useWindowDimensions: () =>
|
||||
geometry.hasFoldDivision ? { width: 669, height: 951 } : { width: 951, height: 669 },
|
||||
AppState: { addEventListener: () => ({ remove: () => {} }) },
|
||||
View: hosts.View,
|
||||
Text: hosts.Text,
|
||||
@@ -53,9 +87,10 @@ vi.mock('lucide-react-native', () => ({
|
||||
}))
|
||||
vi.mock('./BottomDrawer', () => ({ BottomDrawer: hosts.View }))
|
||||
const lists = vi.hoisted(() => {
|
||||
const held: { custom: { items: CustomKey[]; onReorder: (ids: string[]) => void } | null } = {
|
||||
custom: null
|
||||
}
|
||||
const held: {
|
||||
custom: { items: CustomKey[]; onReorder: (ids: string[]) => void } | null
|
||||
builtIn: { items: { id: string }[]; onReorder: (ids: string[]) => void } | null
|
||||
} = { custom: null, builtIn: null }
|
||||
return held
|
||||
})
|
||||
vi.mock('./DragReorderList', () => ({
|
||||
@@ -68,6 +103,9 @@ vi.mock('./DragReorderList', () => ({
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the custom-key list is the only one whose ids start with `custom-`.
|
||||
lists.custom = props as unknown as { items: CustomKey[]; onReorder: (ids: string[]) => void }
|
||||
}
|
||||
if (props.items.some((item) => item.id === 'escape')) {
|
||||
lists.builtIn = props
|
||||
}
|
||||
return props.items.map((item) => props.renderRow(item))
|
||||
}
|
||||
}))
|
||||
@@ -93,19 +131,26 @@ function shown(): string[] {
|
||||
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function settingsScreen() {
|
||||
return createElement(
|
||||
SideRailProvider,
|
||||
null,
|
||||
createElement(GeometryProbe),
|
||||
createElement(TerminalShortcutSettings, {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked reorder list never reads the scroll bindings.
|
||||
scrollRef: {} as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
|
||||
scrollOffsetY: {} as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
|
||||
scrollContentHeight: {} as never,
|
||||
onDragActiveChange: () => {}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
async function mount(): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(TerminalShortcutSettings, {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mocked reorder list never reads the scroll bindings.
|
||||
scrollRef: {} as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
|
||||
scrollOffsetY: {} as never,
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above.
|
||||
scrollContentHeight: {} as never,
|
||||
onDragActiveChange: () => {}
|
||||
})
|
||||
)
|
||||
renderer = create(settingsScreen())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,10 +170,14 @@ async function settle(): Promise<void> {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
geometry.keyboard = 0
|
||||
geometry.duo = true
|
||||
geometry.hasFoldDivision = false
|
||||
store.values = new Map([[CUSTOM_KEYS, JSON.stringify(keys)]])
|
||||
store.holdWrites = null
|
||||
store.refuseWrites = false
|
||||
lists.custom = null
|
||||
lists.builtIn = null
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -161,3 +210,73 @@ describe('custom shortcuts in terminal settings', () => {
|
||||
expect(stored()).toEqual(['custom-a', 'custom-b', 'custom-c'])
|
||||
})
|
||||
})
|
||||
|
||||
it('persists the independent D-pad switch and custom visibility through settings reopen', async () => {
|
||||
await mount()
|
||||
const switchFor = (label: string) => renderer!.root.findByProps({ accessibilityLabel: label })
|
||||
expect(switchFor('Show D-pad').props.value).toBe(true)
|
||||
expect(switchFor('Show Arrow Up shortcut').props.value).toBe(false)
|
||||
act(() => switchFor('Show D-pad').props.onValueChange(false))
|
||||
act(() => switchFor('Show Escape shortcut').props.onValueChange(false))
|
||||
act(() => switchFor('Show Arrow Up shortcut').props.onValueChange(true))
|
||||
act(() => lists.builtIn?.onReorder(lists.builtIn.items.map((key) => key.id).toReversed()))
|
||||
act(() => switchFor('Show a shortcut').props.onValueChange(false))
|
||||
act(() => lists.custom?.onReorder(['custom-c', 'custom-b', 'custom-a']))
|
||||
await settle()
|
||||
expect((await loadTerminalAccessoryLayout()).directionPadEnabled).toBe(false)
|
||||
act(() => renderer?.unmount())
|
||||
await mount()
|
||||
expect(switchFor('Show D-pad').props.value).toBe(false)
|
||||
expect(switchFor('Show Escape shortcut').props.value).toBe(false)
|
||||
expect(switchFor('Show Arrow Up shortcut').props.value).toBe(true)
|
||||
expect((await loadTerminalAccessoryLayout()).visibleBuiltInIds.indexOf('arrowUp')).toBeLessThan(
|
||||
(await loadTerminalAccessoryLayout()).visibleBuiltInIds.indexOf('tab')
|
||||
)
|
||||
expect(switchFor('Show a shortcut').props.value).toBe(false)
|
||||
expect(shown()).toEqual(['custom-c', 'custom-b', 'custom-a'])
|
||||
})
|
||||
|
||||
it('keeps Duo defaults while the keyboard clips the rail, including when saving another shortcut', async () => {
|
||||
await mount()
|
||||
geometry.keyboard = 400
|
||||
await act(async () => renderer!.update(settingsScreen()))
|
||||
const switchFor = (label: string) => renderer!.root.findByProps({ accessibilityLabel: label })
|
||||
expect(drawnHeight).toBe(145)
|
||||
expect.soft(pagePadAvailable).toBe(true)
|
||||
expect.soft(switchFor('Show Arrow Up shortcut').props.value).toBe(false)
|
||||
act(() => switchFor('Show Escape shortcut').props.onValueChange(false))
|
||||
await settle()
|
||||
expect((await loadTerminalAccessoryLayout()).visibleBuiltInIds).not.toContain('arrowUp')
|
||||
act(() => switchFor('Show Arrow Up shortcut').props.onValueChange(true))
|
||||
await settle()
|
||||
act(() => renderer!.unmount())
|
||||
geometry.keyboard = 0
|
||||
await mount()
|
||||
expect(switchFor('Show Arrow Up shortcut').props.value).toBe(true)
|
||||
})
|
||||
|
||||
it('retains navigation defaults on a device without a D-pad rail', async () => {
|
||||
geometry.duo = false
|
||||
geometry.keyboard = 400
|
||||
await mount()
|
||||
expect(pagePadAvailable).toBe(false)
|
||||
expect(
|
||||
renderer!.root.findByProps({ accessibilityLabel: 'Show Arrow Up shortcut' }).props.value
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses inactive fold capability for a cold no-rail screen and preserves explicit D-pad disable', async () => {
|
||||
geometry.duo = false
|
||||
geometry.hasFoldDivision = true
|
||||
await mount()
|
||||
const switchFor = (label: string) => renderer!.root.findByProps({ accessibilityLabel: label })
|
||||
expect(drawnHeight).toBeNull()
|
||||
expect.soft(pagePadAvailable).toBe(true)
|
||||
expect.soft(switchFor('Show Arrow Up shortcut').props.value).toBe(false)
|
||||
act(() => switchFor('Show D-pad').props.onValueChange(false))
|
||||
await settle()
|
||||
act(() => renderer!.unmount())
|
||||
await mount()
|
||||
expect(switchFor('Show D-pad').props.value).toBe(false)
|
||||
expect(switchFor('Show Arrow Up shortcut').props.value).toBe(false)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
|
||||
export const styles = themedStyleSheet(() => ({
|
||||
groupHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
groupTopGap: {
|
||||
marginTop: spacing.xl
|
||||
},
|
||||
groupDescription: {
|
||||
fontSize: typography.bodySize - 1,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
sectionTopGap: {
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
// Reorder list supplies fixed height/trailing grip; pad content only on left.
|
||||
reorderRowContent: {
|
||||
flex: 1,
|
||||
height: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingLeft: spacing.md + 2
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowSublabel: {
|
||||
fontSize: typography.bodySize - 2,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2
|
||||
},
|
||||
keycap: {
|
||||
minWidth: 62,
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
keycapText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontFamily: typography.monoFamily
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
emptyContainer: {
|
||||
padding: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
padding: spacing.md
|
||||
},
|
||||
deleteButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)'
|
||||
},
|
||||
deleteButtonPressed: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.2)'
|
||||
}
|
||||
}))
|
||||
@@ -27,9 +27,6 @@ vi.mock('react-native', () => ({
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
|
||||
}))
|
||||
vi.mock('./layout/side-safe-area', () => ({
|
||||
SideSafeArea: ({ children }: { children: unknown }) => children
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => ({ hostId: 'host-1' }),
|
||||
|
||||
@@ -27,9 +27,6 @@ vi.mock('react-native', () => ({
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
|
||||
}))
|
||||
vi.mock('./layout/side-safe-area', () => ({
|
||||
SideSafeArea: ({ children }: { children: unknown }) => children
|
||||
}))
|
||||
|
||||
vi.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => ({ hostId: dependencies.hostId }),
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { Pressable, StyleSheet, View } from 'react-native'
|
||||
import {
|
||||
List,
|
||||
Plus,
|
||||
Search,
|
||||
SquareTerminal,
|
||||
UserCircle,
|
||||
X,
|
||||
type LucideIcon
|
||||
} from 'lucide-react-native'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import type { HostScreenController } from './use-host-screen-controller'
|
||||
import type { Translate } from '../i18n/mobile-i18n'
|
||||
import { useMobileTranslation } from '../i18n/use-mobile-translation'
|
||||
|
||||
type HostListAction = {
|
||||
id: 'accounts' | 'tasks' | 'search' | 'floating' | 'new'
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
onPress: () => void
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
// One list for the sidebar's toolbar row and the side rail, so both offer the same actions.
|
||||
export function hostListActions(controller: HostScreenController, t: Translate): HostListAction[] {
|
||||
const { actions, connState, floatingWorkspaceEnabled, hostId, state } = controller
|
||||
const offline = connState !== 'connected'
|
||||
const hostPath = `/h/${encodeURIComponent(hostId)}`
|
||||
return [
|
||||
{
|
||||
id: 'accounts',
|
||||
label: t('hostScreen.hostRailActions.accounts', 'Accounts'),
|
||||
icon: UserCircle,
|
||||
onPress: () => actions.navigateFromHostList(`${hostPath}/accounts`),
|
||||
disabled: offline
|
||||
},
|
||||
{
|
||||
id: 'tasks',
|
||||
label: t('hostScreen.hostRailActions.tasks', 'Tasks'),
|
||||
icon: List,
|
||||
onPress: () => actions.navigateFromHostList(`${hostPath}/tasks`),
|
||||
disabled: offline
|
||||
},
|
||||
...(floatingWorkspaceEnabled
|
||||
? [
|
||||
{
|
||||
id: 'floating' as const,
|
||||
label: t('hostScreen.hostRailActions.floatingWorkspace', 'Floating Workspace'),
|
||||
icon: SquareTerminal,
|
||||
onPress: actions.openFloatingWorkspace,
|
||||
disabled: offline
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: 'new',
|
||||
label: t('hostScreen.hostRailActions.newWorkspace', 'New workspace'),
|
||||
icon: Plus,
|
||||
onPress: actions.openNewWorktreeModal,
|
||||
disabled: offline
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
label: state.showSearch
|
||||
? t('hostScreen.hostRailActions.closeSearch', 'Close search')
|
||||
: t('hostScreen.hostRailActions.searchWorkspaces', 'Search workspaces'),
|
||||
icon: state.showSearch ? X : Search,
|
||||
onPress: () => state.setShowSearch((shown) => !shown),
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function RailIcon({ action }: { action: HostListAction }) {
|
||||
useMobileColorScheme()
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.icon,
|
||||
pressed && styles.pressed,
|
||||
action.disabled && styles.disabled
|
||||
]}
|
||||
onPress={action.onPress}
|
||||
disabled={action.disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={action.label}
|
||||
>
|
||||
<action.icon size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function pick(all: HostListAction[], ids: HostListAction['id'][]): HostListAction[] {
|
||||
return ids.flatMap((id) => all.filter((action) => action.id === id))
|
||||
}
|
||||
|
||||
// Beside the sidebar: navigation, then the workspace tools, then New as the one filled button.
|
||||
// On the cover the list keeps its own New button, and search sits at the bottom within thumb reach.
|
||||
export function HostRailActions({
|
||||
controller,
|
||||
placement
|
||||
}: {
|
||||
controller: HostScreenController
|
||||
placement: 'split' | 'cover'
|
||||
}) {
|
||||
const t = useMobileTranslation()
|
||||
useMobileColorScheme()
|
||||
const all = hostListActions(controller, t)
|
||||
if (placement === 'cover') {
|
||||
return (
|
||||
<View style={styles.coverRail}>
|
||||
<View style={styles.rail}>
|
||||
{pick(all, ['accounts', 'tasks', 'floating']).map((action) => (
|
||||
<RailIcon key={action.id} action={action} />
|
||||
))}
|
||||
</View>
|
||||
{pick(all, ['search']).map((action) => (
|
||||
<RailIcon key={action.id} action={action} />
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const create = all.find((action) => action.id === 'new')
|
||||
return (
|
||||
<View style={styles.rail}>
|
||||
{pick(all, ['accounts', 'tasks', 'search', 'floating']).map((action) => (
|
||||
<View key={action.id} style={styles.rail}>
|
||||
{action.id === 'floating' ? <View style={styles.divider} /> : null}
|
||||
<RailIcon action={action} />
|
||||
</View>
|
||||
))}
|
||||
{create ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.create,
|
||||
pressed && styles.createPressed,
|
||||
create.disabled && styles.disabled
|
||||
]}
|
||||
onPress={create.onPress}
|
||||
disabled={create.disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={create.label}
|
||||
>
|
||||
<Plus size={24} color={colors.bgBase} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = themedStyleSheet(() => ({
|
||||
rail: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
coverRail: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: spacing.sm
|
||||
},
|
||||
icon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 22
|
||||
},
|
||||
pressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.4
|
||||
},
|
||||
divider: {
|
||||
width: 32,
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle
|
||||
},
|
||||
create: {
|
||||
marginTop: spacing.xs,
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 26,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.surfaceBright
|
||||
},
|
||||
createPressed: {
|
||||
opacity: 0.85
|
||||
}
|
||||
}))
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ChevronLeft,
|
||||
List,
|
||||
PanelLeftClose,
|
||||
Plus,
|
||||
Search,
|
||||
SquareTerminal,
|
||||
UserCircle,
|
||||
@@ -17,8 +18,6 @@ import { useCalmConnectionState } from '../transport/calm-connection-state'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
import { hostScreenStyles as styles } from './host-screen-styles'
|
||||
import { HostRailActions, hostListActions } from './host-rail-actions'
|
||||
import { useSideRailClaim, useSideRailEdge } from '../layout/side-rail'
|
||||
import type { HostScreenController } from './use-host-screen-controller'
|
||||
|
||||
function isErrorVerdict(v: ConnectionVerdict): boolean {
|
||||
@@ -60,14 +59,6 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
|
||||
} = controller
|
||||
// Why: a drop healed in a few seconds must not flash the host dot amber.
|
||||
const presentedConnState = useCalmConnectionState(connState, lastConnectedAt)
|
||||
// The Duo's side rail takes the list's icons: on the cover always, beside the sidebar only while the detail pane is empty.
|
||||
const railEdge = useSideRailEdge()
|
||||
const railPlacement =
|
||||
railEdge === null ? null : !embedded ? 'cover' : controller.actionsInRail ? 'split' : null
|
||||
const railActions = railPlacement !== null
|
||||
useSideRailClaim(
|
||||
railPlacement ? <HostRailActions controller={controller} placement={railPlacement} /> : null
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.topChrome}>
|
||||
@@ -112,7 +103,7 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
{!embedded && floatingWorkspaceEnabled && !railActions ? (
|
||||
{!embedded && floatingWorkspaceEnabled ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.floatingWorkspaceHeaderButton,
|
||||
@@ -175,34 +166,95 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{railActions ? null : (
|
||||
<View style={styles.embeddedToolbarRow}>
|
||||
{hostListActions(controller, t).map((action) => (
|
||||
<Pressable
|
||||
key={action.id}
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
action.disabled && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={action.onPress}
|
||||
disabled={action.disabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={action.label}
|
||||
>
|
||||
<action.icon
|
||||
size={action.id === 'floating' ? 18 : 16}
|
||||
color={
|
||||
action.disabled
|
||||
? colors.textMuted
|
||||
: action.id === 'new'
|
||||
? colors.textPrimary
|
||||
: colors.textSecondary
|
||||
}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.embeddedToolbarRow}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={() =>
|
||||
actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
|
||||
}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('hostScreen.hostScreenHeader.accounts', 'Accounts')}
|
||||
>
|
||||
<UserCircle
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={() => actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('hostScreen.hostScreenHeader.tasks', 'Tasks')}
|
||||
>
|
||||
<List
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{floatingWorkspaceEnabled ? (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={actions.openFloatingWorkspace}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t(
|
||||
'hostScreen.hostScreenHeader.floatingWorkspace',
|
||||
'Floating Workspace'
|
||||
)}
|
||||
>
|
||||
<SquareTerminal
|
||||
size={18}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
) : null}
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.embeddedToolbarIconButton,
|
||||
connState !== 'connected' && styles.toolbarIconDisabled
|
||||
]}
|
||||
onPress={actions.openNewWorktreeModal}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('hostScreen.hostScreenHeader.newWorkspace', 'New workspace')}
|
||||
>
|
||||
<Plus
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={styles.embeddedToolbarIconButton}
|
||||
onPress={() => state.setShowSearch((s) => !s)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
state.showSearch
|
||||
? t('hostScreen.hostScreenHeader.closeSearch', 'Close search')
|
||||
: t('hostScreen.hostScreenHeader.searchWorkspaces', 'Search workspaces')
|
||||
}
|
||||
>
|
||||
{state.showSearch ? (
|
||||
<X size={16} color={colors.textSecondary} />
|
||||
) : (
|
||||
<Search size={16} color={colors.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.toolbar}>
|
||||
@@ -224,56 +276,51 @@ export function HostScreenHeader({ controller }: { controller: HostScreenControl
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.toolbarSpacer} />
|
||||
{railActions ? null : (
|
||||
<>
|
||||
<Pressable
|
||||
style={styles.searchToggle}
|
||||
onPress={() =>
|
||||
actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
|
||||
}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('hostScreen.hostScreenHeader.accounts', 'Accounts')}
|
||||
>
|
||||
<UserCircle
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={styles.searchToggle}
|
||||
onPress={() =>
|
||||
actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)
|
||||
}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('hostScreen.hostScreenHeader.tasks', 'Tasks')}
|
||||
>
|
||||
<List
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.searchToggle}
|
||||
onPress={() =>
|
||||
actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/accounts`)
|
||||
}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('hostScreen.hostScreenHeader.accounts', 'Accounts')}
|
||||
>
|
||||
<UserCircle
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={styles.searchToggle}
|
||||
onPress={() => state.setShowSearch((s) => !s)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
state.showSearch
|
||||
? t('hostScreen.hostScreenHeader.closeSearch', 'Close search')
|
||||
: t('hostScreen.hostScreenHeader.searchWorkspaces', 'Search workspaces')
|
||||
}
|
||||
>
|
||||
{state.showSearch ? (
|
||||
<X size={16} color={colors.textSecondary} />
|
||||
) : (
|
||||
<Search size={16} color={colors.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
<Pressable
|
||||
style={styles.searchToggle}
|
||||
onPress={() => actions.navigateFromHostList(`/h/${encodeURIComponent(hostId)}/tasks`)}
|
||||
disabled={connState !== 'connected'}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('hostScreen.hostScreenHeader.tasks', 'Tasks')}
|
||||
>
|
||||
<List
|
||||
size={16}
|
||||
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={styles.searchToggle}
|
||||
onPress={() => state.setShowSearch((s) => !s)}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
state.showSearch
|
||||
? t('hostScreen.hostScreenHeader.closeSearch', 'Close search')
|
||||
: t('hostScreen.hostScreenHeader.searchWorkspaces', 'Search workspaces')
|
||||
}
|
||||
>
|
||||
{state.showSearch ? (
|
||||
<X size={16} color={colors.textSecondary} />
|
||||
) : (
|
||||
<Search size={16} color={colors.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -37,15 +37,12 @@ export type HostScreenProps = {
|
||||
// Layout-mounted sidebars lack route params; receive them explicitly.
|
||||
hostId?: string
|
||||
onHideSidebar?: () => void
|
||||
// The detail pane is empty, so a side rail beside it may carry the sidebar's actions.
|
||||
actionsInRail?: boolean
|
||||
}
|
||||
|
||||
export function useHostScreenController({
|
||||
embedded = false,
|
||||
hostId: hostIdProp,
|
||||
onHideSidebar,
|
||||
actionsInRail = false
|
||||
onHideSidebar
|
||||
}: HostScreenProps = {}) {
|
||||
useMobileTranslation()
|
||||
const params = useLocalSearchParams<{ hostId: string; notice?: string }>()
|
||||
@@ -182,7 +179,6 @@ export function useHostScreenController({
|
||||
noticeParam,
|
||||
now,
|
||||
onHideSidebar,
|
||||
actionsInRail,
|
||||
reconnectAttempts,
|
||||
relayRecovery,
|
||||
routeNotice,
|
||||
|
||||
@@ -1392,14 +1392,6 @@
|
||||
"hostPendingCreationRow": {
|
||||
"creatingWorkspace": "Arbeitsbereich wird erstellt…"
|
||||
},
|
||||
"hostRailActions": {
|
||||
"accounts": "Konten",
|
||||
"closeSearch": "Suche schließen",
|
||||
"floatingWorkspace": "Schwebender Arbeitsbereich",
|
||||
"newWorkspace": "Neuer Arbeitsbereich",
|
||||
"searchWorkspaces": "Arbeitsbereiche durchsuchen",
|
||||
"tasks": "Aufgaben"
|
||||
},
|
||||
"hostScreenHeader": {
|
||||
"accounts": "Konten",
|
||||
"activeFilters_one": "Arbeitsbereichsoptionen ({{count}} Filter aktiv)",
|
||||
@@ -2333,9 +2325,6 @@
|
||||
"unsavedMarkdown": "Ungespeicherte Markdown-Änderungen",
|
||||
"urlPrompt": "URL eingeben oder für einen neuen Tab leer lassen."
|
||||
},
|
||||
"sideRail": {
|
||||
"backToWorktrees": "Zurück zu den Worktrees"
|
||||
},
|
||||
"sourceControlLaunchAgent": {
|
||||
"noEnabledAgent": "Auf dem Host dieses Arbeitsbereichs wurde kein aktivierter KI-Agent erkannt.",
|
||||
"savedAgentUnavailable": "Der für diese Aktion gespeicherte Agent ist auf dem Host dieses Arbeitsbereichs nicht verfügbar."
|
||||
|
||||
@@ -550,14 +550,6 @@
|
||||
"hostPendingCreationRow": {
|
||||
"creatingWorkspace": "Creating workspace…"
|
||||
},
|
||||
"hostRailActions": {
|
||||
"accounts": "Accounts",
|
||||
"tasks": "Tasks",
|
||||
"floatingWorkspace": "Floating Workspace",
|
||||
"newWorkspace": "New workspace",
|
||||
"closeSearch": "Close search",
|
||||
"searchWorkspaces": "Search workspaces"
|
||||
},
|
||||
"hostScreenHeader": {
|
||||
"workspaceOptions": "Workspace options",
|
||||
"activeFilters_one": "Workspace options ({{count}} filter active)",
|
||||
@@ -569,7 +561,8 @@
|
||||
"accounts": "Accounts",
|
||||
"tasks": "Tasks",
|
||||
"closeSearch": "Close search",
|
||||
"searchWorkspaces": "Search workspaces"
|
||||
"searchWorkspaces": "Search workspaces",
|
||||
"newWorkspace": "New workspace"
|
||||
},
|
||||
"hostScreenOverlays": {
|
||||
"deleteWorktree": "Delete Worktree",
|
||||
@@ -1209,7 +1202,11 @@
|
||||
"addShortcut": "Add Custom Shortcut…",
|
||||
"addDescription": "Create key combo or text macro",
|
||||
"moreKeys": "MORE KEYS",
|
||||
"moreKeysDescription": "These keys sit under ⋯ on the terminal key row. Toggle keys to show or hide them, and hold the grip to drag a key into the order you want. A key already on the row, like Esc or the arrows, is left out of ⋯."
|
||||
"moreKeysDescription": "These keys sit under ⋯ on the terminal key row. Toggle keys to show or hide them, and hold the grip to drag a key into the order you want. A key already on the row, like Esc or the arrows, is left out of ⋯.",
|
||||
"directionPad": "DIRECTION PAD",
|
||||
"showDirectionPad": "Show D-pad",
|
||||
"showDirectionPadDescription": "Pin the arrow pad in the side rail. Without it, the rail lists the arrow keys.",
|
||||
"showShortcut": "Show {{label}} shortcut"
|
||||
},
|
||||
"voiceModelList": {
|
||||
"modelSize": "{{size}} MB",
|
||||
@@ -1816,7 +1813,12 @@
|
||||
"reconnectToDesktop": "Reconnect to desktop",
|
||||
"openFileExplorer": "Open file explorer",
|
||||
"openSourceControl": "Open source control",
|
||||
"moreActions": "More session actions"
|
||||
"moreActions": "More session actions",
|
||||
"terminalLayout": "Terminal layout",
|
||||
"newTab": "New tab",
|
||||
"quickCommands": "Quick commands",
|
||||
"fileExplorer": "File explorer",
|
||||
"sourceControl": "Source control"
|
||||
},
|
||||
"headerMoreActionsSheet": {
|
||||
"agentHistory": "Agent History",
|
||||
@@ -1861,7 +1863,8 @@
|
||||
"tabStatus": "{{title}}, {{status}}",
|
||||
"newTab": "New tab",
|
||||
"quickCommandsUpdateRequired": "Desktop update required for quick commands",
|
||||
"checkingCapabilities": "Checking desktop capabilities — try again in a moment"
|
||||
"checkingCapabilities": "Checking desktop capabilities — try again in a moment",
|
||||
"terminalLayout": "Terminal layout"
|
||||
},
|
||||
"terminalInputActions": {
|
||||
"sendingImage": "Sending image",
|
||||
@@ -2154,9 +2157,6 @@
|
||||
"agentStartedIn": "Agent started in {{label}}",
|
||||
"agentStarted": "Agent started"
|
||||
},
|
||||
"sideRail": {
|
||||
"backToWorktrees": "Back to worktrees"
|
||||
},
|
||||
"useAttachmentInputLeaseGate": {
|
||||
"reconnecting": "Attach failed (reconnecting)"
|
||||
},
|
||||
@@ -2503,6 +2503,28 @@
|
||||
"deleteKeyHint": "Long press to delete this key",
|
||||
"addCustomKey": "Add custom key",
|
||||
"addKey": "Add key"
|
||||
},
|
||||
"terminalLayoutSheet": {
|
||||
"title": "Terminal layout",
|
||||
"oneTerminal": "One terminal",
|
||||
"selected": "Selected",
|
||||
"auto": "Auto",
|
||||
"autoHint": "Split to fit the available space",
|
||||
"sideBySide": "Side by side",
|
||||
"sideBySideHint": "Two columns, even with the sidebar open",
|
||||
"stacked": "Stacked",
|
||||
"stackedHint": "Two rows in any orientation",
|
||||
"selectedHint": "Selected · {{hint}}"
|
||||
},
|
||||
"terminalPaneView": {
|
||||
"focusedPane": "{{title}}, focused",
|
||||
"terminal": "Terminal",
|
||||
"tapToFocusPane": "{{title}}, tap to focus",
|
||||
"inputPane": "{{title}} · Input"
|
||||
},
|
||||
"terminalSplitLayout": {
|
||||
"saveFailedTitle": "Layout could not be saved",
|
||||
"saveFailedMessage": "This layout applies for now. Choose it again to retry saving."
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@@ -3975,5 +3997,17 @@
|
||||
"webviewReadyWatchdog": {
|
||||
"initializeFailed": "Terminal did not initialize - no ready signal from the terminal view"
|
||||
}
|
||||
},
|
||||
"mobileWebShell": {
|
||||
"terminalDirectionPad": {
|
||||
"up": "D-pad up",
|
||||
"left": "D-pad left",
|
||||
"right": "D-pad right",
|
||||
"down": "D-pad down",
|
||||
"hint": "Sends an arrow key. Hold to repeat."
|
||||
},
|
||||
"terminalKeyRail": {
|
||||
"ctrlC": "Ctrl+C"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1435,14 +1435,6 @@
|
||||
"hostPendingCreationRow": {
|
||||
"creatingWorkspace": "Creando espacio de trabajo…"
|
||||
},
|
||||
"hostRailActions": {
|
||||
"accounts": "Cuentas",
|
||||
"closeSearch": "Cerrar búsqueda",
|
||||
"floatingWorkspace": "Espacio de trabajo flotante",
|
||||
"newWorkspace": "Nuevo espacio de trabajo",
|
||||
"searchWorkspaces": "Buscar espacios de trabajo",
|
||||
"tasks": "Tareas"
|
||||
},
|
||||
"hostScreenHeader": {
|
||||
"accounts": "Cuentas",
|
||||
"activeFilters_many": "Opciones de espacio de trabajo ({{count}} filtros activos)",
|
||||
@@ -2408,9 +2400,6 @@
|
||||
"unsavedMarkdown": "Cambios en Markdown sin guardar",
|
||||
"urlPrompt": "Introduce una URL o deja el campo en blanco para abrir una pestaña nueva."
|
||||
},
|
||||
"sideRail": {
|
||||
"backToWorktrees": "Volver a los worktrees"
|
||||
},
|
||||
"sourceControlLaunchAgent": {
|
||||
"noEnabledAgent": "No se detectó ningún agente de IA habilitado en este host de espacio de trabajo.",
|
||||
"savedAgentUnavailable": "El agente guardado para esta acción no está disponible en el host de este espacio de trabajo."
|
||||
|
||||
@@ -1435,14 +1435,6 @@
|
||||
"hostPendingCreationRow": {
|
||||
"creatingWorkspace": "Création de l'espace de travail…"
|
||||
},
|
||||
"hostRailActions": {
|
||||
"accounts": "Comptes",
|
||||
"closeSearch": "Fermer la recherche",
|
||||
"floatingWorkspace": "Espace de travail flottant",
|
||||
"newWorkspace": "Nouvel espace de travail",
|
||||
"searchWorkspaces": "Rechercher des espaces de travail",
|
||||
"tasks": "Tâches"
|
||||
},
|
||||
"hostScreenHeader": {
|
||||
"accounts": "Comptes",
|
||||
"activeFilters_many": "Options des espaces de travail ({{count}} filtres actifs)",
|
||||
@@ -2408,9 +2400,6 @@
|
||||
"unsavedMarkdown": "Modifications Markdown non enregistrées",
|
||||
"urlPrompt": "Saisissez une URL ou laissez le champ vide pour un nouvel onglet."
|
||||
},
|
||||
"sideRail": {
|
||||
"backToWorktrees": "Retour aux worktrees"
|
||||
},
|
||||
"sourceControlLaunchAgent": {
|
||||
"noEnabledAgent": "Aucun agent IA activé n'a été détecté sur l'hôte de cet espace de travail.",
|
||||
"savedAgentUnavailable": "L'agent enregistré pour cette action n'est pas disponible sur l'hôte de cet espace de travail."
|
||||
|
||||
@@ -1388,14 +1388,6 @@
|
||||
"hostPendingCreationRow": {
|
||||
"creatingWorkspace": "ワークスペースを作成中…"
|
||||
},
|
||||
"hostRailActions": {
|
||||
"accounts": "アカウント",
|
||||
"closeSearch": "検索を閉じる",
|
||||
"floatingWorkspace": "フローティングワークスペース",
|
||||
"newWorkspace": "新規ワークスペース",
|
||||
"searchWorkspaces": "ワークスペースの検索",
|
||||
"tasks": "タスク"
|
||||
},
|
||||
"hostScreenHeader": {
|
||||
"accounts": "アカウント",
|
||||
"activeFilters_one": "ワークスペースのオプション ({{count}} 件のフィルターが有効)",
|
||||
@@ -2329,9 +2321,6 @@
|
||||
"unsavedMarkdown": "Markdown の未保存の変更",
|
||||
"urlPrompt": "URL を入力してください。空欄のままにすると新規タブが開きます。"
|
||||
},
|
||||
"sideRail": {
|
||||
"backToWorktrees": "ワークツリーに戻る"
|
||||
},
|
||||
"sourceControlLaunchAgent": {
|
||||
"noEnabledAgent": "このワークスペースホストでは有効な AI Agent が検出されませんでした。",
|
||||
"savedAgentUnavailable": "この操作用に保存された Agent は、このワークスペースホストでは利用できません。"
|
||||
|
||||
@@ -1388,14 +1388,6 @@
|
||||
"hostPendingCreationRow": {
|
||||
"creatingWorkspace": "워크스페이스 만드는 중…"
|
||||
},
|
||||
"hostRailActions": {
|
||||
"accounts": "계정",
|
||||
"closeSearch": "검색 닫기",
|
||||
"floatingWorkspace": "플로팅 워크스페이스",
|
||||
"newWorkspace": "새로운 워크스페이스",
|
||||
"searchWorkspaces": "워크스페이스 검색",
|
||||
"tasks": "작업"
|
||||
},
|
||||
"hostScreenHeader": {
|
||||
"accounts": "계정",
|
||||
"activeFilters_one": "워크스페이스 옵션 (필터 {{count}}개 활성화됨)",
|
||||
@@ -2329,9 +2321,6 @@
|
||||
"unsavedMarkdown": "저장되지 않은 Markdown 변경 사항",
|
||||
"urlPrompt": "URL을 입력하세요. 비워 두면 새 탭이 열립니다."
|
||||
},
|
||||
"sideRail": {
|
||||
"backToWorktrees": "워크트리로 돌아가기"
|
||||
},
|
||||
"sourceControlLaunchAgent": {
|
||||
"noEnabledAgent": "이 워크스페이스 호스트에서는 활성화된 AI 에이전트가 감지되지 않았습니다.",
|
||||
"savedAgentUnavailable": "이 작업에 저장된 에이전트는 이 워크스페이스 호스트에서 사용할 수 없습니다."
|
||||
|
||||
@@ -1435,14 +1435,6 @@
|
||||
"hostPendingCreationRow": {
|
||||
"creatingWorkspace": "Criando espaço de trabalho…"
|
||||
},
|
||||
"hostRailActions": {
|
||||
"accounts": "Contas",
|
||||
"closeSearch": "Fechar busca",
|
||||
"floatingWorkspace": "Espaço de Trabalho Flutuante",
|
||||
"newWorkspace": "Novo espaço de trabalho",
|
||||
"searchWorkspaces": "Buscar espaços de trabalho",
|
||||
"tasks": "Tarefas"
|
||||
},
|
||||
"hostScreenHeader": {
|
||||
"accounts": "Contas",
|
||||
"activeFilters_many": "Opções do espaço de trabalho ({{count}} filtros ativos)",
|
||||
@@ -2408,9 +2400,6 @@
|
||||
"unsavedMarkdown": "Alterações em Markdown não salvas",
|
||||
"urlPrompt": "Digite uma URL ou deixe em branco para abrir uma nova aba."
|
||||
},
|
||||
"sideRail": {
|
||||
"backToWorktrees": "Voltar para as worktrees"
|
||||
},
|
||||
"sourceControlLaunchAgent": {
|
||||
"noEnabledAgent": "Nenhum agente de IA ativado foi detectado neste host do espaço de trabalho.",
|
||||
"savedAgentUnavailable": "O agente salvo para esta ação não está disponível neste host do espaço de trabalho."
|
||||
|
||||
@@ -1478,14 +1478,6 @@
|
||||
"hostPendingCreationRow": {
|
||||
"creatingWorkspace": "Создание рабочего пространства…"
|
||||
},
|
||||
"hostRailActions": {
|
||||
"accounts": "Учётные записи",
|
||||
"closeSearch": "Закрыть поиск",
|
||||
"floatingWorkspace": "Плавающее рабочее пространство",
|
||||
"newWorkspace": "Новое рабочее пространство",
|
||||
"searchWorkspaces": "Поиск рабочих пространств",
|
||||
"tasks": "Задачи"
|
||||
},
|
||||
"hostScreenHeader": {
|
||||
"accounts": "Учётные записи",
|
||||
"activeFilters_few": "Параметры рабочих пространств (активны {{count}} фильтра)",
|
||||
@@ -2483,9 +2475,6 @@
|
||||
"unsavedMarkdown": "Несохранённые изменения в Markdown",
|
||||
"urlPrompt": "Введите URL или оставьте поле пустым, чтобы открыть новую вкладку."
|
||||
},
|
||||
"sideRail": {
|
||||
"backToWorktrees": "Вернуться к рабочим деревьям"
|
||||
},
|
||||
"sourceControlLaunchAgent": {
|
||||
"noEnabledAgent": "На сервере этого рабочего пространства не найдено включённых ИИ-агентов.",
|
||||
"savedAgentUnavailable": "Агент, выбранный для этого действия, недоступен на сервере этого рабочего пространства."
|
||||
|
||||
@@ -1392,14 +1392,6 @@
|
||||
"hostPendingCreationRow": {
|
||||
"creatingWorkspace": "Çalışma alanı oluşturuluyor…"
|
||||
},
|
||||
"hostRailActions": {
|
||||
"accounts": "Hesaplar",
|
||||
"closeSearch": "Aramayı kapat",
|
||||
"floatingWorkspace": "Yüzen Çalışma Alanı",
|
||||
"newWorkspace": "Yeni çalışma alanı",
|
||||
"searchWorkspaces": "Çalışma alanlarını ara",
|
||||
"tasks": "Görevler"
|
||||
},
|
||||
"hostScreenHeader": {
|
||||
"accounts": "Hesaplar",
|
||||
"activeFilters_one": "Çalışma alanı seçenekleri ({{count}} filtre etkin)",
|
||||
@@ -2333,9 +2325,6 @@
|
||||
"unsavedMarkdown": "Kaydedilmemiş Markdown değişiklikleri",
|
||||
"urlPrompt": "Bir URL girin veya yeni sekme için boş bırakın."
|
||||
},
|
||||
"sideRail": {
|
||||
"backToWorktrees": "Worktree'lere dön"
|
||||
},
|
||||
"sourceControlLaunchAgent": {
|
||||
"noEnabledAgent": "Bu çalışma alanı sunucusunda etkin bir AI ajanı algılanmadı.",
|
||||
"savedAgentUnavailable": "Bu işlem için kaydedilen ajan, bu çalışma alanı sunucusunda kullanılamıyor."
|
||||
|
||||
@@ -1388,14 +1388,6 @@
|
||||
"hostPendingCreationRow": {
|
||||
"creatingWorkspace": "正在建立工作區…"
|
||||
},
|
||||
"hostRailActions": {
|
||||
"accounts": "帳戶",
|
||||
"closeSearch": "關閉搜尋",
|
||||
"floatingWorkspace": "浮動工作區",
|
||||
"newWorkspace": "新工作區",
|
||||
"searchWorkspaces": "搜尋工作區",
|
||||
"tasks": "任務"
|
||||
},
|
||||
"hostScreenHeader": {
|
||||
"accounts": "帳戶",
|
||||
"activeFilters_one": "工作區選項(已啟用 {{count}} 個篩選條件)",
|
||||
@@ -2329,9 +2321,6 @@
|
||||
"unsavedMarkdown": "尚未儲存的 Markdown 變更",
|
||||
"urlPrompt": "請輸入網址,或留空以開啟新分頁。"
|
||||
},
|
||||
"sideRail": {
|
||||
"backToWorktrees": "返回工作樹"
|
||||
},
|
||||
"sourceControlLaunchAgent": {
|
||||
"noEnabledAgent": "此工作區主機上未偵測到已啟用的 AI 代理程式。",
|
||||
"savedAgentUnavailable": "先前為此動作設定的代理程式無法在此工作區主機上使用。"
|
||||
|
||||
@@ -1388,14 +1388,6 @@
|
||||
"hostPendingCreationRow": {
|
||||
"creatingWorkspace": "正在创建工作区…"
|
||||
},
|
||||
"hostRailActions": {
|
||||
"accounts": "账户",
|
||||
"closeSearch": "关闭搜索",
|
||||
"floatingWorkspace": "浮动工作区",
|
||||
"newWorkspace": "新工作区",
|
||||
"searchWorkspaces": "搜索工作区",
|
||||
"tasks": "任务"
|
||||
},
|
||||
"hostScreenHeader": {
|
||||
"accounts": "账户",
|
||||
"activeFilters_one": "工作区选项(已启用 {{count}} 个筛选条件)",
|
||||
@@ -2329,9 +2321,6 @@
|
||||
"unsavedMarkdown": "未保存的 Markdown 更改",
|
||||
"urlPrompt": "输入 URL,或留空以新建标签页。"
|
||||
},
|
||||
"sideRail": {
|
||||
"backToWorktrees": "返回工作树列表"
|
||||
},
|
||||
"sourceControlLaunchAgent": {
|
||||
"noEnabledAgent": "此工作区主机上未检测到已启用的 AI 智能体。",
|
||||
"savedAgentUnavailable": "为此操作保存的智能体在此工作区主机上不可用。"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getFoldSplit } from './fold-division'
|
||||
|
||||
// Measured on the iPhone Duo simulator (iOS 27.1), inner display, half folded; the frames include
|
||||
// the 20pt margins UIKit reserves on each side of the fold.
|
||||
const INNER_LANDSCAPE = { x: 0, y: 0, width: 951, height: 669 }
|
||||
const BOOK_FOLD = { x: 455.5, y: 0, width: 40, height: 669 }
|
||||
const INNER_PORTRAIT = { x: 0, y: 0, width: 669, height: 951 }
|
||||
const TABLETOP_FOLD = { x: 0, y: 455.5, width: 669, height: 40 }
|
||||
|
||||
describe('getFoldSplit', () => {
|
||||
it('splits left from right in book posture and top from bottom in tabletop posture', () => {
|
||||
expect(getFoldSplit([BOOK_FOLD], INNER_LANDSCAPE)).toEqual({
|
||||
axis: 'vertical',
|
||||
start: 455.5,
|
||||
end: 495.5,
|
||||
size: 951
|
||||
})
|
||||
expect(getFoldSplit([TABLETOP_FOLD], INNER_PORTRAIT)).toEqual({
|
||||
axis: 'horizontal',
|
||||
start: 455.5,
|
||||
end: 495.5,
|
||||
size: 951
|
||||
})
|
||||
})
|
||||
|
||||
it('answers in the coordinates of a frame inset in the window', () => {
|
||||
expect(getFoldSplit([BOOK_FOLD], { x: 59, y: 0, width: 808, height: 669 })).toEqual({
|
||||
axis: 'vertical',
|
||||
start: 396.5,
|
||||
end: 436.5,
|
||||
size: 808
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a fold at the edge of a Split View window, which splits nothing', () => {
|
||||
expect(getFoldSplit([BOOK_FOLD], { x: 0, y: 0, width: 469, height: 669 })).toBeNull()
|
||||
expect(getFoldSplit([BOOK_FOLD], { x: 482, y: 0, width: 469, height: 669 })).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores regions that do not cross the frame or carry no usable size', () => {
|
||||
expect(getFoldSplit([{ ...BOOK_FOLD, height: 300 }], INNER_LANDSCAPE)).toBeNull()
|
||||
expect(getFoldSplit([{ ...BOOK_FOLD, width: 0 }], INNER_LANDSCAPE)).toBeNull()
|
||||
expect(getFoldSplit([{ ...BOOK_FOLD, x: Number.NaN }], INNER_LANDSCAPE)).toBeNull()
|
||||
expect(getFoldSplit([BOOK_FOLD], { ...INNER_LANDSCAPE, width: 0 })).toBeNull()
|
||||
expect(getFoldSplit([], INNER_LANDSCAPE)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ReservedRect } from '../../modules/orca-reserved-regions/src'
|
||||
|
||||
/** Where a fold splits a frame: the band from `start` to `end` along the axis it divides. */
|
||||
export type FoldSplit = {
|
||||
/** `vertical`: the fold runs top to bottom and splits left from right (book posture). */
|
||||
axis: 'vertical' | 'horizontal'
|
||||
start: number
|
||||
end: number
|
||||
/** The frame's extent along the split, so `size - end` is the room after the fold. */
|
||||
size: number
|
||||
}
|
||||
|
||||
type Frame = { x: number; y: number; width: number; height: number }
|
||||
|
||||
function isUsable(rect: ReservedRect): boolean {
|
||||
return (
|
||||
[rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The fold that splits `frame` into two usable halves, in the frame's own coordinates; null when
|
||||
* none does. A fold only counts when it crosses the frame from edge to edge and leaves room on
|
||||
* both sides; one at the frame's edge (a Split View window beside it) splits nothing.
|
||||
*/
|
||||
export function getFoldSplit(divisions: readonly ReservedRect[], frame: Frame): FoldSplit | null {
|
||||
if (!(frame.width > 0 && frame.height > 0)) {
|
||||
return null
|
||||
}
|
||||
for (const division of divisions) {
|
||||
if (!isUsable(division)) {
|
||||
continue
|
||||
}
|
||||
const vertical = division.height >= division.width
|
||||
const crossStart = vertical ? division.y - frame.y : division.x - frame.x
|
||||
const crossLength = vertical ? division.height : division.width
|
||||
const crossSize = vertical ? frame.height : frame.width
|
||||
if (crossStart > 0 || crossStart + crossLength < crossSize) {
|
||||
continue
|
||||
}
|
||||
const size = vertical ? frame.width : frame.height
|
||||
const start = vertical ? division.x - frame.x : division.y - frame.y
|
||||
const end = start + (vertical ? division.width : division.height)
|
||||
if (start <= 0 || end >= size) {
|
||||
continue
|
||||
}
|
||||
return { axis: vertical ? 'vertical' : 'horizontal', start, end, size }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -20,10 +20,7 @@ vi.mock('react-native', () => ({
|
||||
PanResponder: { create: () => ({ panHandlers: {} }) },
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
SafeAreaView: 'SafeAreaView',
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 })
|
||||
}))
|
||||
vi.mock('react-native-safe-area-context', () => ({ SafeAreaView: 'SafeAreaView' }))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ChevronLeft: 'ChevronLeft',
|
||||
Folder: 'Folder',
|
||||
@@ -44,6 +41,7 @@ vi.mock('./responsive-layout', () => ({
|
||||
useResponsiveLayout: () => ({ isWideLayout: true, width: 1024 })
|
||||
}))
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
HOST_SIDEBAR_DEFAULT_WIDTH: 320,
|
||||
HOST_SIDEBAR_MIN_WIDTH: 240,
|
||||
HOST_SIDEBAR_MAX_WIDTH: 480,
|
||||
loadHostSidebarWidth: async () => 320,
|
||||
@@ -60,10 +58,13 @@ vi.mock('../host-screen/HostScreen', () => ({
|
||||
})
|
||||
}))
|
||||
vi.mock('../navigation/host-stack', () => ({
|
||||
HostStack: () =>
|
||||
createElement(MobileSessionHeader, { controller: headerController(), backInRail: false })
|
||||
HostStack: () => createElement(MobileSessionHeader, { controller: headerController() })
|
||||
}))
|
||||
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
|
||||
vi.mock('./use-top-chrome-space', () => ({ useTopChromeSpace: () => ({}) }))
|
||||
vi.mock('../session/MobileSessionHeaderMoreActionsSheet', () => ({
|
||||
MobileSessionHeaderMoreActionsSheet: () => null
|
||||
}))
|
||||
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: () => {} }))
|
||||
vi.mock('../transport/host-client-hooks', () => ({
|
||||
useDisconnectHostClient: vi.fn(),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { OrcaReservedRegions, type ReservedRegions } from '../../modules/orca-reserved-regions/src'
|
||||
|
||||
const NO_REGIONS: ReservedRegions = { divisions: [], occlusions: [] }
|
||||
|
||||
let current = NO_REGIONS
|
||||
const listeners = new Set<() => void>()
|
||||
let observing = false
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
// Why: one app-lifetime native subscription; restarting it would report no regions until the
|
||||
// next layout pass.
|
||||
if (!observing && OrcaReservedRegions) {
|
||||
observing = true
|
||||
OrcaReservedRegions.addListener('onChange', (regions) => {
|
||||
current = regions
|
||||
for (const notify of listeners) {
|
||||
notify()
|
||||
}
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** Active fold and camera regions in window points; none off iPhone Duo or before iOS 27.1. */
|
||||
export function useReservedRegions(): ReservedRegions {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => current,
|
||||
() => NO_REGIONS
|
||||
)
|
||||
}
|
||||
|
||||
/** Future native geometry only; cached frames must not overwrite a newer keyboard event. */
|
||||
export function subscribeReservedRegionChanges(
|
||||
listener: (regions: ReservedRegions) => void
|
||||
): () => void {
|
||||
return subscribe(() => listener(current))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMemo, useSyncExternalStore } from 'react'
|
||||
import { usePageBridgeClientIfPresent } from '../transport/client-context.web'
|
||||
import { EMPTY_PAGE_GEOMETRY } from '../mobile-web-shell/bridge/bridge-page-geometry'
|
||||
|
||||
export function usePageGeometry() {
|
||||
const client = usePageBridgeClientIfPresent()
|
||||
return useSyncExternalStore(
|
||||
client?.onPageGeometryUpdate ?? (() => () => undefined),
|
||||
() => client?.getShellSession()?.pageGeometry ?? EMPTY_PAGE_GEOMETRY
|
||||
)
|
||||
}
|
||||
export function useReservedRegions() {
|
||||
const { occlusions } = usePageGeometry()
|
||||
return useMemo(() => ({ divisions: [], occlusions }), [occlusions])
|
||||
}
|
||||
@@ -21,20 +21,6 @@ describe('responsive layout metrics', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('splits an unfolded iPhone Duo in both holds and keeps its cover phone-like', () => {
|
||||
expect(getResponsiveLayoutMetrics(951, 669)).toMatchObject({ isWideLayout: true })
|
||||
expect(getResponsiveLayoutMetrics(669, 951)).toMatchObject({ isWideLayout: true })
|
||||
expect(getResponsiveLayoutMetrics(466, 678)).toMatchObject({
|
||||
isTabletLayout: false,
|
||||
isWideLayout: false,
|
||||
isLandscapePhone: false
|
||||
})
|
||||
expect(getResponsiveLayoutMetrics(678, 466)).toMatchObject({
|
||||
isWideLayout: false,
|
||||
isLandscapePhone: true
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps narrow iPad split windows phone-like', () => {
|
||||
expect(getResponsiveLayoutMetrics(560, 1024)).toMatchObject({
|
||||
isTabletLayout: false,
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
import { spacing } from '../theme/mobile-theme'
|
||||
|
||||
// Host split geometry; a detail pane narrower than the narrowest iPhone cramps chat and terminals.
|
||||
export const HOST_SIDEBAR_MIN_WIDTH = 280
|
||||
export const SPLIT_DETAIL_MIN_WIDTH = 375
|
||||
const HOST_SIDEBAR_DEFAULT_MAX_WIDTH = 340
|
||||
|
||||
// 30% of the window until the user drags it: room for workspace names, the rest goes to the session.
|
||||
export function defaultHostSidebarWidth(windowWidth: number): number {
|
||||
return Math.min(
|
||||
HOST_SIDEBAR_DEFAULT_MAX_WIDTH,
|
||||
Math.max(HOST_SIDEBAR_MIN_WIDTH, Math.round(windowWidth * 0.3))
|
||||
)
|
||||
}
|
||||
|
||||
// Use actual window size so narrow iPad splits keep phone-like layouts; unfolded foldables split in both orientations.
|
||||
const WIDE_LAYOUT_MIN_WIDTH = HOST_SIDEBAR_MIN_WIDTH + SPLIT_DETAIL_MIN_WIDTH
|
||||
// Use actual window size so narrow iPad splits keep phone-like layouts.
|
||||
const WIDE_LAYOUT_MIN_WIDTH = 700
|
||||
|
||||
// Require room in both dimensions so rotating a landscape phone cannot switch it to tablet UI.
|
||||
const TABLET_LAYOUT_MIN_SHORT_SIDE = 600
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getSideRailStrip, sideRailStripAboveKeyboard } from './side-rail-strip'
|
||||
|
||||
// Measured on the iPhone Duo simulator (iOS 27.1). The status column sits in the right inset.
|
||||
const INNER_LANDSCAPE = {
|
||||
windowWidth: 951,
|
||||
windowHeight: 669,
|
||||
insets: { top: 0, right: 84, bottom: 34, left: 0 },
|
||||
occlusions: [{ x: 867, y: 0, width: 84, height: 124 }]
|
||||
}
|
||||
const COVER_PORTRAIT = {
|
||||
windowWidth: 466,
|
||||
windowHeight: 678,
|
||||
insets: { top: 0, right: 84, bottom: 34, left: 0 },
|
||||
occlusions: [
|
||||
{ x: 399.67, y: 29.33, width: 37, height: 37 },
|
||||
{ x: 382, y: 0, width: 84, height: 174 }
|
||||
]
|
||||
}
|
||||
|
||||
describe('getSideRailStrip', () => {
|
||||
it('offers the strip below the iPhone Duo status column, open or closed', () => {
|
||||
expect(getSideRailStrip(INNER_LANDSCAPE)).toEqual({
|
||||
side: 'right',
|
||||
width: 84,
|
||||
top: 124,
|
||||
bottom: 34,
|
||||
height: 511
|
||||
})
|
||||
expect(getSideRailStrip(COVER_PORTRAIT)).toEqual({
|
||||
side: 'right',
|
||||
width: 84,
|
||||
top: 174,
|
||||
bottom: 34,
|
||||
height: 470
|
||||
})
|
||||
})
|
||||
|
||||
it('finds the strip on the left when the device is turned the other way', () => {
|
||||
expect(
|
||||
getSideRailStrip({
|
||||
...INNER_LANDSCAPE,
|
||||
insets: { top: 0, right: 0, bottom: 34, left: 84 },
|
||||
occlusions: [{ x: 0, y: 545, width: 84, height: 124 }]
|
||||
})
|
||||
).toEqual({ side: 'left', width: 84, top: 0, bottom: 124, height: 545 })
|
||||
})
|
||||
|
||||
it('offers none where nothing occludes the side inset, as on a landscape iPhone', () => {
|
||||
expect(getSideRailStrip({ ...INNER_LANDSCAPE, occlusions: [] })).toBeNull()
|
||||
expect(
|
||||
getSideRailStrip({
|
||||
windowWidth: 852,
|
||||
windowHeight: 393,
|
||||
insets: { top: 0, right: 59, bottom: 21, left: 59 },
|
||||
occlusions: []
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('offers none beside a camera island or anything that does not reach a screen edge', () => {
|
||||
expect(
|
||||
getSideRailStrip({
|
||||
windowWidth: 956,
|
||||
windowHeight: 440,
|
||||
insets: { top: 0, right: 62, bottom: 21, left: 62 },
|
||||
occlusions: [{ x: 11, y: 157, width: 37, height: 126 }]
|
||||
})
|
||||
).toBeNull()
|
||||
expect(
|
||||
getSideRailStrip({
|
||||
...INNER_LANDSCAPE,
|
||||
occlusions: [{ x: 867, y: 200, width: 84, height: 124 }]
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('offers none when the inset is too narrow or the occlusion leaves too little below it', () => {
|
||||
expect(
|
||||
getSideRailStrip({ ...INNER_LANDSCAPE, insets: { ...INNER_LANDSCAPE.insets, right: 40 } })
|
||||
).toBeNull()
|
||||
expect(
|
||||
getSideRailStrip({
|
||||
...INNER_LANDSCAPE,
|
||||
occlusions: [{ x: 867, y: 0, width: 84, height: 520 }]
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('ends the strip above the software keyboard, and offers none when it covers it all', () => {
|
||||
const strip = getSideRailStrip(INNER_LANDSCAPE)
|
||||
expect(sideRailStripAboveKeyboard(strip, 0)).toBe(strip)
|
||||
expect(sideRailStripAboveKeyboard(strip, 300)).toEqual({
|
||||
side: 'right',
|
||||
width: 84,
|
||||
top: 124,
|
||||
bottom: 300,
|
||||
height: 245
|
||||
})
|
||||
expect(sideRailStripAboveKeyboard(strip, 545)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ReservedRect } from '../../modules/orca-reserved-regions/src'
|
||||
|
||||
/** The part of a side inset strip below what occludes it, in window points. */
|
||||
export type SideRailStrip = {
|
||||
side: 'left' | 'right'
|
||||
width: number
|
||||
top: number
|
||||
bottom: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function hasTerminalDirectionPadSpace(strip: SideRailStrip | null): strip is SideRailStrip {
|
||||
return strip !== null && strip.height >= 224 && strip.width >= 60
|
||||
}
|
||||
|
||||
/** Narrower than a key; the iPhone Duo's status column is 84. */
|
||||
const MIN_STRIP_WIDTH = 60
|
||||
/** Room for at least a few keys below the occluded part. */
|
||||
const MIN_STRIP_HEIGHT = 160
|
||||
|
||||
/**
|
||||
* The side inset strip a rail of controls can use: a whole-height side inset that something as wide
|
||||
* as it occludes only in part, like the iPhone Duo's status column, less that part. Null for any
|
||||
* other window, so a landscape iPhone's rounded corners or camera island never get one.
|
||||
*/
|
||||
export function getSideRailStrip(args: {
|
||||
windowWidth: number
|
||||
windowHeight: number
|
||||
insets: { top: number; right: number; bottom: number; left: number }
|
||||
occlusions: readonly ReservedRect[]
|
||||
}): SideRailStrip | null {
|
||||
const { windowWidth, windowHeight, insets, occlusions } = args
|
||||
for (const side of ['right', 'left'] as const) {
|
||||
const width = insets[side]
|
||||
if (!(width >= MIN_STRIP_WIDTH)) {
|
||||
continue
|
||||
}
|
||||
const stripStart = side === 'right' ? windowWidth - width : 0
|
||||
const stripEnd = side === 'right' ? windowWidth : width
|
||||
const inStrip = occlusions.filter(
|
||||
(region) => region.x < stripEnd && region.x + region.width > stripStart
|
||||
)
|
||||
// The status column spans the strip's width from a screen edge; a camera island does neither.
|
||||
const statusColumn = inStrip.some(
|
||||
(region) =>
|
||||
region.x <= stripStart + 1 &&
|
||||
region.x + region.width >= stripEnd - 1 &&
|
||||
(region.y <= 1 || region.y + region.height >= windowHeight - 1)
|
||||
)
|
||||
if (!statusColumn) {
|
||||
continue
|
||||
}
|
||||
const occluded = inStrip
|
||||
.map((region) => ({ from: region.y, to: region.y + region.height }))
|
||||
.sort((a, b) => a.from - b.from)
|
||||
// The longest run of the strip nothing occludes, between the top and bottom insets.
|
||||
let best = { from: 0, to: 0 }
|
||||
let cursor = insets.top
|
||||
for (const span of [...occluded, { from: windowHeight - insets.bottom, to: windowHeight }]) {
|
||||
if (span.from - cursor > best.to - best.from) {
|
||||
best = { from: cursor, to: span.from }
|
||||
}
|
||||
cursor = Math.max(cursor, span.to)
|
||||
}
|
||||
if (best.to - best.from >= MIN_STRIP_HEIGHT) {
|
||||
return {
|
||||
side,
|
||||
width,
|
||||
top: best.from,
|
||||
bottom: windowHeight - best.to,
|
||||
height: best.to - best.from
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The strip less what the software keyboard covers of its bottom; null when it covers all of it. */
|
||||
export function sideRailStripAboveKeyboard(
|
||||
strip: SideRailStrip | null,
|
||||
keyboardHeight: number
|
||||
): SideRailStrip | null {
|
||||
if (strip === null || keyboardHeight <= strip.bottom) {
|
||||
return strip
|
||||
}
|
||||
const height = strip.height - (keyboardHeight - strip.bottom)
|
||||
return height > 0 ? { ...strip, bottom: keyboardHeight, height } : null
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SideRailOnTopContext, SideRailProvider, useSideRailClaim } from './side-rail'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
View: 'View',
|
||||
StyleSheet: { create: (styles: unknown) => styles }
|
||||
}))
|
||||
|
||||
function RailLabel(_props: { text: string }) {
|
||||
return null
|
||||
}
|
||||
|
||||
function Claimant({ label }: { label: string | null }) {
|
||||
useSideRailClaim(label === null ? null : createElement(RailLabel, { text: label }))
|
||||
return null
|
||||
}
|
||||
|
||||
function screen(label: string | null, onTop: boolean) {
|
||||
return createElement(
|
||||
SideRailOnTopContext.Provider,
|
||||
{ value: onTop },
|
||||
createElement(Claimant, { label })
|
||||
)
|
||||
}
|
||||
|
||||
describe('side rail', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function render(edge: 'right' | null, ...screens: ReturnType<typeof screen>[]) {
|
||||
const tree = (
|
||||
<SideRailProvider edge={edge} width={84} bottom={34}>
|
||||
{screens}
|
||||
</SideRailProvider>
|
||||
)
|
||||
act(() => {
|
||||
if (renderer) {
|
||||
renderer.update(tree)
|
||||
} else {
|
||||
renderer = create(tree)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function railText(): string[] {
|
||||
return (renderer?.root.findAllByType(RailLabel) ?? []).map((node) => node.props.text)
|
||||
}
|
||||
|
||||
it('shows the top screen, never one a stack keeps beneath it', () => {
|
||||
render('right', screen('host actions', true), screen('keys', false))
|
||||
expect(railText()).toEqual(['host actions'])
|
||||
})
|
||||
|
||||
it('gives the rail back when a claim ends, and draws nothing without a rail', () => {
|
||||
render('right', screen('keys', true))
|
||||
expect(railText()).toEqual(['keys'])
|
||||
render('right', screen(null, true))
|
||||
expect(railText()).toEqual([])
|
||||
render(null, screen('keys', true))
|
||||
expect(railText()).toEqual([])
|
||||
})
|
||||
})
|
||||
+111
-95
@@ -2,129 +2,145 @@ import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type ReactNode
|
||||
} from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { StyleSheet, useWindowDimensions, View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useReservedRegions } from './reserved-regions'
|
||||
import { useKeyboardOcclusion } from '../platform/keyboard-occlusion'
|
||||
import {
|
||||
getSideRailStrip,
|
||||
hasTerminalDirectionPadSpace,
|
||||
sideRailStripAboveKeyboard,
|
||||
type SideRailStrip
|
||||
} from './side-rail-strip'
|
||||
|
||||
export type SideRailEdge = 'left' | 'right'
|
||||
/** One screen's claim on the strip; each screen holds its own. */
|
||||
type SideRailOwner = symbol
|
||||
|
||||
// Only a vertical status bar (iPhone Duo: 84pt) is this wide; landscape Dynamic Island insets stop at 62pt.
|
||||
const SIDE_RAIL_MIN_INSET = 72
|
||||
// Clears the clock and signal cluster at the top of the bar (it ends near 97pt unfolded and 147pt on the cover).
|
||||
const SIDE_RAIL_TOP_CLEARANCE = 156
|
||||
type RailContent = { owner: SideRailOwner; node: ReactNode }
|
||||
|
||||
export function sideRailEdge(insets: { left: number; right: number }): SideRailEdge | null {
|
||||
if (insets.right >= SIDE_RAIL_MIN_INSET) {
|
||||
return 'right'
|
||||
}
|
||||
return insets.left >= SIDE_RAIL_MIN_INSET ? 'left' : null
|
||||
}
|
||||
|
||||
type Claim = { id: string; node: ReactNode }
|
||||
|
||||
// Latest claim wins; screens claim only while focused, so a covered screen never shows through.
|
||||
function createSideRailStore() {
|
||||
let claims: Claim[] = []
|
||||
function createRailStore() {
|
||||
let content: RailContent | null = null
|
||||
const listeners = new Set<() => void>()
|
||||
const emit = () => listeners.forEach((listener) => listener())
|
||||
const notify = () => {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
return {
|
||||
read: () => content,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
},
|
||||
top: () => claims.at(-1)?.node ?? null,
|
||||
set: (id: string, node: ReactNode) => {
|
||||
const index = claims.findIndex((claim) => claim.id === id)
|
||||
claims =
|
||||
index === -1
|
||||
? [...claims, { id, node }]
|
||||
: claims.map((claim, i) => (i === index ? { id, node } : claim))
|
||||
emit()
|
||||
show: (owner: SideRailOwner, node: ReactNode) => {
|
||||
content = { owner, node }
|
||||
notify()
|
||||
},
|
||||
release: (id: string) => {
|
||||
claims = claims.filter((claim) => claim.id !== id)
|
||||
emit()
|
||||
// Only the screen that showed it takes it down, so a screen leaving never hides its successor's.
|
||||
hide: (owner: SideRailOwner) => {
|
||||
if (content?.owner === owner) {
|
||||
content = null
|
||||
notify()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SideRailStore = ReturnType<typeof createSideRailStore>
|
||||
type RailStore = ReturnType<typeof createRailStore>
|
||||
|
||||
const SideRailContext = createContext<{ edge: SideRailEdge | null; store: SideRailStore } | null>(
|
||||
null
|
||||
)
|
||||
const SideRailContext = createContext<{
|
||||
strip: SideRailStrip | null
|
||||
directionPadAvailable: boolean
|
||||
store: RailStore
|
||||
} | null>(null)
|
||||
|
||||
// False for a screen a stack keeps mounted beneath the top one; its claim must not show over the screen on top.
|
||||
export const SideRailOnTopContext = createContext(true)
|
||||
|
||||
// Which edge has a rail this screen may fill, or null when the window has none.
|
||||
export function useSideRailEdge(): SideRailEdge | null {
|
||||
return useContext(SideRailContext)?.edge ?? null
|
||||
}
|
||||
|
||||
// Show node in the rail while it is non-null and its screen is on top; pass null when there is nothing for it.
|
||||
export function useSideRailClaim(node: ReactNode | null): void {
|
||||
const rail = useContext(SideRailContext)
|
||||
const onTop = useContext(SideRailOnTopContext)
|
||||
const id = useId()
|
||||
const store = rail?.edge && onTop ? rail.store : null
|
||||
const claiming = store !== null && node !== null
|
||||
// Update in place so a re-render keeps the claim's position; release only when the claim ends.
|
||||
useEffect(() => {
|
||||
if (store !== null && node !== null) {
|
||||
store.set(id, node)
|
||||
}
|
||||
}, [store, id, node])
|
||||
useEffect(() => {
|
||||
if (store === null || !claiming) {
|
||||
return
|
||||
}
|
||||
return () => store.release(id)
|
||||
}, [store, id, claiming])
|
||||
}
|
||||
|
||||
// Owned by SideSafeArea: it already pads the rail edge, so the rail is that padding.
|
||||
export function SideRailProvider({
|
||||
edge,
|
||||
width,
|
||||
bottom,
|
||||
children
|
||||
}: {
|
||||
edge: SideRailEdge | null
|
||||
width: number
|
||||
bottom: number
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [store] = useState(createSideRailStore)
|
||||
const value = useMemo(() => ({ edge, store }), [edge, store])
|
||||
/**
|
||||
* Lets one screen draw controls in a side inset strip the screens are laid out clear of, such as
|
||||
* the iPhone Duo's status column. Sits outside `SideSafeArea`, which pads every screen out of it.
|
||||
*/
|
||||
export function SideRailProvider({ children }: { children: ReactNode }) {
|
||||
const [store] = useState(createRailStore)
|
||||
const { width, height } = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { occlusions, hasFoldDivision } = useReservedRegions()
|
||||
// Landscape, the keyboard covers the strip's bottom; the rail then holds fewer keys, not hidden ones.
|
||||
const keyboard = Math.max(0, useKeyboardOcclusion())
|
||||
const unoccludedStrip = useMemo(
|
||||
() => getSideRailStrip({ windowWidth: width, windowHeight: height, insets, occlusions }),
|
||||
[width, height, insets, occlusions]
|
||||
)
|
||||
const strip = useMemo(
|
||||
() => sideRailStripAboveKeyboard(unoccludedStrip, keyboard),
|
||||
[unoccludedStrip, keyboard]
|
||||
)
|
||||
// Keyboard clipping changes what fits now, not this device's shortcut defaults.
|
||||
const directionPadAvailable =
|
||||
hasFoldDivision === true || hasTerminalDirectionPadSpace(unoccludedStrip)
|
||||
const value = useMemo(
|
||||
() => ({ strip, directionPadAvailable, store }),
|
||||
[strip, directionPadAvailable, store]
|
||||
)
|
||||
return (
|
||||
<SideRailContext.Provider value={value}>
|
||||
{children}
|
||||
{edge ? (
|
||||
<View
|
||||
style={[styles.rail, { [edge]: 0, width, paddingBottom: bottom }]}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<SideRailSlot store={store} />
|
||||
</View>
|
||||
) : null}
|
||||
<SideRailOutlet />
|
||||
</SideRailContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function SideRailSlot({ store }: { store: SideRailStore }) {
|
||||
const node = useSyncExternalStore(store.subscribe, store.top, store.top)
|
||||
return node
|
||||
function SideRailOutlet() {
|
||||
const context = useContext(SideRailContext)
|
||||
const content = useSyncExternalStore(
|
||||
context?.store.subscribe ?? (() => () => undefined),
|
||||
() => context?.store.read() ?? null
|
||||
)
|
||||
const strip = context?.strip
|
||||
if (!strip || !content) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.outlet,
|
||||
{ width: strip.width, top: strip.top, bottom: strip.bottom },
|
||||
strip.side === 'right' ? { right: 0 } : { left: 0 }
|
||||
]}
|
||||
>
|
||||
{content.node}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/** The side strip this window has to draw in; null for a window without one. */
|
||||
export function useSideRailStrip(): SideRailStrip | null {
|
||||
return useContext(SideRailContext)?.strip ?? null
|
||||
}
|
||||
|
||||
export function useSideRailDirectionPadAvailable(): boolean {
|
||||
return useContext(SideRailContext)?.directionPadAvailable ?? false
|
||||
}
|
||||
|
||||
/** Draws `node` in the side strip while this screen is mounted; null draws nothing. */
|
||||
export function useSideRailContent(node: ReactNode | null): void {
|
||||
// The store outlives strip changes, which replace the context value; re-registering on them would flash the rail.
|
||||
const store = useContext(SideRailContext)?.store
|
||||
const [owner] = useState(() => Symbol('side-rail-owner'))
|
||||
useEffect(() => {
|
||||
if (!store || node === null) {
|
||||
return
|
||||
}
|
||||
store.show(owner, node)
|
||||
return () => store.hide(owner)
|
||||
}, [store, owner, node])
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
rail: {
|
||||
position: 'absolute',
|
||||
top: SIDE_RAIL_TOP_CLEARANCE,
|
||||
bottom: 0
|
||||
}
|
||||
outlet: { position: 'absolute' }
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import { act, create as createRenderer, type ReactTestRenderer } from 'react-tes
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ReactNode } from 'react'
|
||||
import { getFoldSplit, type FoldSplit } from './fold-division'
|
||||
import type { ReservedRegions } from '../../modules/orca-reserved-regions/src'
|
||||
import type { EdgeInsets, Rect } from 'react-native-safe-area-context'
|
||||
|
||||
const renderers = new Set<ReturnType<typeof createRenderer>>()
|
||||
@@ -19,8 +21,13 @@ function unmountRenderers(): void {
|
||||
renderers.clear()
|
||||
}
|
||||
|
||||
const geometry = vi.hoisted((): { regions: ReservedRegions; platform: { OS: string } } => ({
|
||||
regions: { divisions: [], occlusions: [] },
|
||||
platform: { OS: 'ios' }
|
||||
}))
|
||||
vi.mock('./reserved-regions', () => ({ useReservedRegions: () => geometry.regions }))
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'ios' },
|
||||
Platform: geometry.platform,
|
||||
View: 'View',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles }
|
||||
}))
|
||||
@@ -45,21 +52,29 @@ const { SideSafeArea, useWindowSafeAreaInsets } = await import('./side-safe-area
|
||||
const WINDOW = { top: 0, right: 48, bottom: 21, left: 59 }
|
||||
const FRAME = { x: 0, y: 0, width: 852, height: 393 }
|
||||
|
||||
function renderInLandscape() {
|
||||
const read: { screen?: EdgeInsets; frame?: Rect; modal?: EdgeInsets } = {}
|
||||
function renderInLandscape(window = WINDOW, frame = FRAME, nested = false) {
|
||||
const read: { screen?: EdgeInsets; frame?: Rect; modal?: EdgeInsets; fold?: FoldSplit | null } =
|
||||
{}
|
||||
function Probe() {
|
||||
read.screen = useSafeAreaInsets()
|
||||
read.frame = useSafeAreaFrame()
|
||||
read.fold = getFoldSplit(geometry.regions.divisions, read.frame)
|
||||
read.modal = useWindowSafeAreaInsets()
|
||||
return null
|
||||
}
|
||||
let tree: ReactTestRenderer | undefined
|
||||
act(() => {
|
||||
tree = create(
|
||||
<SafeAreaFrameContext.Provider value={FRAME}>
|
||||
<SafeAreaInsetsContext.Provider value={WINDOW}>
|
||||
<SafeAreaFrameContext.Provider value={frame}>
|
||||
<SafeAreaInsetsContext.Provider value={window}>
|
||||
<SideSafeArea>
|
||||
<Probe />
|
||||
{nested ? (
|
||||
<SideSafeArea>
|
||||
<Probe />
|
||||
</SideSafeArea>
|
||||
) : (
|
||||
<Probe />
|
||||
)}
|
||||
</SideSafeArea>
|
||||
</SafeAreaInsetsContext.Provider>
|
||||
</SafeAreaFrameContext.Provider>
|
||||
@@ -85,4 +100,95 @@ describe('the app laid out clear of the side insets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(unmountRenderers)
|
||||
afterEach(() => {
|
||||
unmountRenderers()
|
||||
geometry.regions = { divisions: [], occlusions: [] }
|
||||
geometry.platform.OS = 'ios'
|
||||
})
|
||||
|
||||
const DUO_FRAME = { x: 0, y: 0, width: 951, height: 669 }
|
||||
const DUO_INSETS = { top: 0, right: 84, bottom: 34, left: 0 }
|
||||
function duoGeometry() {
|
||||
geometry.regions = {
|
||||
divisions: [],
|
||||
occlusions: [{ x: 867, y: 0, width: 84, height: 120 }],
|
||||
windowSafeArea: { bounds: DUO_FRAME, insets: DUO_INSETS }
|
||||
}
|
||||
}
|
||||
function topPadding(tree: ReactTestRenderer) {
|
||||
return tree.root
|
||||
.findAll((node) => String(node.type) === 'View')
|
||||
.map((node) => Object.assign({}, ...[node.props.style].flat()).paddingTop ?? 0)
|
||||
}
|
||||
|
||||
it('keeps the full Duo height without adding a top band across nested areas', () => {
|
||||
duoGeometry()
|
||||
const { tree, read } = renderInLandscape(DUO_INSETS, DUO_FRAME, true)
|
||||
expect(topPadding(tree)).toEqual([0, 0])
|
||||
expect(read.frame).toEqual({ x: 0, y: 0, width: 867, height: 669 })
|
||||
expect(read.screen).toEqual({ top: 0, right: 0, bottom: 34, left: 0 })
|
||||
})
|
||||
|
||||
it('adds no Duo edge spacing when the keyboard changes or a banner offsets the frame', () => {
|
||||
duoGeometry()
|
||||
geometry.regions.keyboardInset = 600
|
||||
expect(topPadding(renderInLandscape(DUO_INSETS, DUO_FRAME).tree)).toEqual([0])
|
||||
expect(topPadding(renderInLandscape(DUO_INSETS, { ...DUO_FRAME, y: 12 }).tree)).toEqual([0])
|
||||
expect(topPadding(renderInLandscape(DUO_INSETS, { ...DUO_FRAME, y: 30 }).tree)).toEqual([0])
|
||||
})
|
||||
|
||||
it.each(['web', 'android'])('does not apply the native Duo workaround on %s', (platform) => {
|
||||
duoGeometry()
|
||||
geometry.platform.OS = platform
|
||||
expect(topPadding(renderInLandscape(DUO_INSETS, DUO_FRAME).tree)).toEqual([0])
|
||||
})
|
||||
|
||||
it('leaves portrait top-safe-area donation and ordinary iPad/phone geometry unchanged', () => {
|
||||
geometry.regions = {
|
||||
divisions: [],
|
||||
occlusions: [{ x: 470, y: 0, width: 199, height: 84 }],
|
||||
windowSafeArea: {
|
||||
bounds: { x: 0, y: 0, width: 669, height: 951 },
|
||||
insets: { top: 84, right: 0, bottom: 34, left: 0 }
|
||||
}
|
||||
}
|
||||
expect(
|
||||
topPadding(
|
||||
renderInLandscape(
|
||||
{ top: 84, right: 0, bottom: 34, left: 0 },
|
||||
{ x: 0, y: 0, width: 669, height: 951 }
|
||||
).tree
|
||||
)
|
||||
).toEqual([0])
|
||||
geometry.regions = { divisions: [], occlusions: [] }
|
||||
expect(topPadding(renderInLandscape().tree)).toEqual([0])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
division: { x: 0, y: 320, width: 951, height: 20 },
|
||||
expected: { axis: 'horizontal', start: 320, end: 340, size: 669 }
|
||||
},
|
||||
{
|
||||
division: { x: 430, y: 0, width: 10, height: 669 },
|
||||
expected: { axis: 'vertical', start: 430, end: 440, size: 867 }
|
||||
}
|
||||
])(
|
||||
'keeps the window hinge aligned without a synthetic top offset: $expected.axis',
|
||||
({ division, expected }) => {
|
||||
duoGeometry()
|
||||
geometry.regions.divisions = [division]
|
||||
const { read, tree } = renderInLandscape(DUO_INSETS, DUO_FRAME, true)
|
||||
expect(topPadding(tree)).toEqual([0, 0])
|
||||
expect(read.frame).toEqual({ x: 0, y: 0, width: 867, height: 669 })
|
||||
expect(read.fold).toEqual(expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves genuine top insets for the screen and modal without a synthetic guard', () => {
|
||||
duoGeometry()
|
||||
const { tree, read } = renderInLandscape({ ...DUO_INSETS, top: 18 }, DUO_FRAME)
|
||||
expect(topPadding(tree)).toEqual([0])
|
||||
expect(read.screen?.top).toBe(18)
|
||||
expect(read.modal?.top).toBe(18)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import { createContext, useContext, useMemo, type ReactNode } from 'react'
|
||||
import { Platform, StyleSheet, View } from 'react-native'
|
||||
import {
|
||||
SafeAreaFrameContext,
|
||||
@@ -17,67 +8,39 @@ import {
|
||||
useSafeAreaInsets,
|
||||
type EdgeInsets
|
||||
} from 'react-native-safe-area-context'
|
||||
import { SideRailProvider, sideRailEdge } from './side-rail'
|
||||
|
||||
const WindowSafeAreaInsetsContext = createContext<EdgeInsets | null>(null)
|
||||
const SideRailReleaseContext = createContext<Dispatch<SetStateAction<number>> | null>(null)
|
||||
|
||||
// Pad side cutouts/navigation bars once, then expose the padded frame and insets so descendants neither pad twice nor size past it.
|
||||
// Nesting one is safe: it pads only what an outer one left unpadded.
|
||||
export function SideSafeArea({ children }: { children: ReactNode }) {
|
||||
const insets = useSafeAreaInsets()
|
||||
const frame = useSafeAreaFrame()
|
||||
const [releases, setReleases] = useState(0)
|
||||
const railEdge = sideRailEdge(insets)
|
||||
const released = releases > 0 ? railEdge : null
|
||||
const left = released === 'left' ? 0 : insets.left
|
||||
const right = released === 'right' ? 0 : insets.right
|
||||
const areaInsets = useMemo(
|
||||
() => ({ ...insets, left: insets.left - left, right: insets.right - right }),
|
||||
[insets, left, right]
|
||||
)
|
||||
const areaInsets = useMemo(() => ({ ...insets, left: 0, right: 0 }), [insets])
|
||||
const areaFrame = useMemo(
|
||||
() => ({ ...frame, x: frame.x + left, width: Math.max(0, frame.width - left - right) }),
|
||||
[frame, left, right]
|
||||
() => ({
|
||||
...frame,
|
||||
x: frame.x + insets.left,
|
||||
width: Math.max(0, frame.width - insets.left - insets.right)
|
||||
}),
|
||||
[frame, insets.left, insets.right]
|
||||
)
|
||||
const area = (
|
||||
<WindowSafeAreaInsetsContext.Provider value={insets}>
|
||||
<SafeAreaFrameContext.Provider value={areaFrame}>
|
||||
<SafeAreaInsetsContext.Provider value={areaInsets}>
|
||||
<SideRailReleaseContext.Provider value={setReleases}>
|
||||
{children}
|
||||
</SideRailReleaseContext.Provider>
|
||||
{children}
|
||||
</SafeAreaInsetsContext.Provider>
|
||||
</SafeAreaFrameContext.Provider>
|
||||
</WindowSafeAreaInsetsContext.Provider>
|
||||
)
|
||||
// Native SafeAreaView measures its nearest native provider, so nest one inside the padding; web reads the adjusted context.
|
||||
return (
|
||||
<View style={[styles.area, { paddingLeft: left, paddingRight: right }]}>
|
||||
<SideRailProvider
|
||||
edge={released ? null : railEdge}
|
||||
width={railEdge ? insets[railEdge] : 0}
|
||||
bottom={insets.bottom}
|
||||
>
|
||||
{Platform.OS === 'web' ? area : <SafeAreaProvider>{area}</SafeAreaProvider>}
|
||||
</SideRailProvider>
|
||||
<View style={[styles.area, { paddingLeft: insets.left, paddingRight: insets.right }]}>
|
||||
{Platform.OS === 'web' ? area : <SafeAreaProvider>{area}</SafeAreaProvider>}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// A layout whose screens reach the Duo's status-bar rail themselves keeps it unpadded while mounted.
|
||||
// Pages then get the rail's width as an inset and draw in it; native views beneath wrap themselves in SideSafeArea.
|
||||
export function useReleasedSideRail(release: boolean): void {
|
||||
const setReleases = useContext(SideRailReleaseContext)
|
||||
useEffect(() => {
|
||||
if (!release || setReleases === null) {
|
||||
return
|
||||
}
|
||||
setReleases((count) => count + 1)
|
||||
return () => setReleases((count) => count - 1)
|
||||
}, [release, setReleases])
|
||||
}
|
||||
|
||||
// Modals own a separate window outside SideSafeArea and need the original window insets.
|
||||
export function useWindowSafeAreaInsets(): EdgeInsets {
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { topChromeSpace } from './top-chrome-space'
|
||||
|
||||
const frame = { x: 0, y: 0, width: 669, height: 88 }
|
||||
describe('header sharing the Duo top status strip', () => {
|
||||
it('uses the space beside the reported top-right system region', () => {
|
||||
expect(topChromeSpace(frame, 84, [{ x: 470, y: 0, width: 199, height: 84 }])).toEqual({
|
||||
left: 0,
|
||||
right: 199,
|
||||
minHeight: 84
|
||||
})
|
||||
})
|
||||
it('handles a left region and a header offset by a sidebar independently', () => {
|
||||
expect(topChromeSpace(frame, 84, [{ x: 0, y: 0, width: 199, height: 84 }])).toEqual({
|
||||
left: 199,
|
||||
right: 0,
|
||||
minHeight: 84
|
||||
})
|
||||
expect(
|
||||
topChromeSpace({ ...frame, x: 200, width: 469 }, 84, [
|
||||
{ x: 570, y: 0, width: 99, height: 84 }
|
||||
])
|
||||
).toEqual({ left: 0, right: 99, minHeight: 84 })
|
||||
})
|
||||
it('keeps normal safe areas for unknown, centered, full-width or crowded obstructions', () => {
|
||||
expect(topChromeSpace(frame, 84, [])).toBeNull()
|
||||
expect(topChromeSpace(frame, 84, [{ x: 300, y: 0, width: 69, height: 84 }])).toBeNull()
|
||||
expect(topChromeSpace(frame, 84, [{ x: 0, y: 0, width: 669, height: 84 }])).toBeNull()
|
||||
expect(topChromeSpace(frame, 84, [{ x: 200, y: 0, width: 469, height: 84 }])).toBeNull()
|
||||
expect(topChromeSpace(frame, 0, [{ x: 470, y: 0, width: 199, height: 84 }])).toBeNull()
|
||||
})
|
||||
it('does not collide with an additional camera region or assume an inset was not already consumed', () => {
|
||||
expect(
|
||||
topChromeSpace(frame, 84, [
|
||||
{ x: 470, y: 0, width: 199, height: 84 },
|
||||
{ x: 0, y: 20, width: 30, height: 30 }
|
||||
])
|
||||
).toEqual({ left: 30, right: 199, minHeight: 84 })
|
||||
expect(
|
||||
topChromeSpace({ ...frame, y: 84 }, 84, [{ x: 470, y: 0, width: 199, height: 84 }])
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ReservedRect } from '../../modules/orca-reserved-regions/src'
|
||||
|
||||
/** A header can share a top strip only when the system reports its full edge obstruction. */
|
||||
export function topChromeSpace(
|
||||
frame: ReservedRect | null,
|
||||
topInset: number,
|
||||
occlusions: readonly ReservedRect[]
|
||||
): { left: number; right: number; minHeight: number } | null {
|
||||
if (!frame || topInset <= 0 || frame.width <= 0) {
|
||||
return null
|
||||
}
|
||||
const end = frame.x + frame.width
|
||||
const obstacles = occlusions.filter(
|
||||
(rect) =>
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.x < end &&
|
||||
rect.x + rect.width > frame.x &&
|
||||
rect.y < frame.y + topInset &&
|
||||
rect.y + rect.height > frame.y
|
||||
)
|
||||
if (
|
||||
!obstacles.some(
|
||||
(rect) =>
|
||||
rect.y <= frame.y + 1 &&
|
||||
rect.y + rect.height >= frame.y + topInset - 1 &&
|
||||
(rect.x <= frame.x + 1 || rect.x + rect.width >= end - 1)
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const spans = obstacles
|
||||
.map((rect) => ({
|
||||
start: Math.max(frame.x, rect.x),
|
||||
end: Math.min(end, rect.x + rect.width)
|
||||
}))
|
||||
.sort((a, b) => a.start - b.start)
|
||||
let cursor = frame.x
|
||||
let best = { start: frame.x, end: frame.x }
|
||||
for (const span of [...spans, { start: end, end }]) {
|
||||
if (span.start - cursor > best.end - best.start) {
|
||||
best = { start: cursor, end: span.start }
|
||||
}
|
||||
cursor = Math.max(cursor, span.end)
|
||||
}
|
||||
// Keep a usable tab strip; a camera island or full-width status bar cannot donate a header row.
|
||||
if (best.end - best.start < 240) {
|
||||
return null
|
||||
}
|
||||
return { left: best.start - frame.x, right: end - best.end, minHeight: topInset }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useSafeAreaFrame } from 'react-native-safe-area-context'
|
||||
import { OrcaReservedRegions } from '../../modules/orca-reserved-regions/src'
|
||||
import { getFoldSplit, type FoldSplit } from './fold-division'
|
||||
import { useReservedRegions } from './reserved-regions'
|
||||
|
||||
function useMeasuredFoldSplit(): FoldSplit | null {
|
||||
const { divisions } = useReservedRegions()
|
||||
// Below `SideSafeArea` the frame is the padded area, in window points like the regions.
|
||||
const frame = useSafeAreaFrame()
|
||||
return useMemo(() => getFoldSplit(divisions, frame), [divisions, frame])
|
||||
}
|
||||
|
||||
const noFoldSplit = (): FoldSplit | null => null
|
||||
|
||||
/**
|
||||
* The fold splitting the area `SideSafeArea` lays screens out in, in that area's coordinates.
|
||||
* Chosen once: a platform without the module never reports a fold, so it measures nothing.
|
||||
*/
|
||||
export const useFoldSplit = OrcaReservedRegions ? useMeasuredFoldSplit : noFoldSplit
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { useWindowDimensions } from 'react-native'
|
||||
import { usePageBridgeClient } from '../transport/client-context.web'
|
||||
import type { FoldSplit } from './fold-division'
|
||||
|
||||
/** The fold the shell measured over this page, in the page's px; null when none splits it. */
|
||||
export function useFoldSplit(): FoldSplit | null {
|
||||
const client = usePageBridgeClient()
|
||||
const division = useSyncExternalStore(
|
||||
client.onFoldDivisionUpdate,
|
||||
() => client.getShellSession()?.foldDivision ?? null
|
||||
)
|
||||
const { width, height } = useWindowDimensions()
|
||||
if (division === null) {
|
||||
return null
|
||||
}
|
||||
return { ...division, size: division.axis === 'vertical' ? width : height }
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useSideRailStrip, useSideRailDirectionPadAvailable } from './side-rail'
|
||||
export function useShellSideRail(): boolean {
|
||||
return useSideRailStrip() !== null
|
||||
}
|
||||
|
||||
export function useDirectionPadAvailable(): boolean {
|
||||
return useSideRailDirectionPadAvailable()
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { usePageGeometry } from './reserved-regions.web'
|
||||
export function useShellSideRail(): boolean {
|
||||
return usePageGeometry().sideRail
|
||||
}
|
||||
|
||||
export function useDirectionPadAvailable(): boolean {
|
||||
return usePageGeometry().directionPadAvailable === true
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useWindowDimensions, type View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import type { ReservedRect } from '../../modules/orca-reserved-regions/src'
|
||||
import { useReservedRegions } from './reserved-regions'
|
||||
import { topChromeSpace } from './top-chrome-space'
|
||||
|
||||
export function useTopChromeSpace() {
|
||||
const ref = useRef<View>(null)
|
||||
const [frame, setFrame] = useState<ReservedRect | null>(null)
|
||||
const dimensions = useWindowDimensions()
|
||||
const { top } = useSafeAreaInsets()
|
||||
const { occlusions } = useReservedRegions()
|
||||
const onLayout = useCallback(
|
||||
() =>
|
||||
ref.current?.measureInWindow((x, y, width, height) => {
|
||||
setFrame((current) =>
|
||||
current?.x === x &&
|
||||
current.y === y &&
|
||||
current.width === width &&
|
||||
current.height === height
|
||||
? current
|
||||
: { x, y, width, height }
|
||||
)
|
||||
}),
|
||||
[]
|
||||
)
|
||||
useEffect(onLayout, [onLayout, top, occlusions, dimensions.width, dimensions.height])
|
||||
return { ref, onLayout, space: topChromeSpace(frame, top, occlusions) }
|
||||
}
|
||||
@@ -306,12 +306,14 @@ describe('the hybrid shell screen', () => {
|
||||
dependencies.client = createFakeRpcClient()
|
||||
const tree = await renderScreen(readyState('session-one'))
|
||||
const view = byName(tree, 'ShellViewProbe')[0]
|
||||
const refreshesBeforeReady = dependencies.storageRefreshes
|
||||
await act(async () => {
|
||||
view.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } })
|
||||
view.props.onBridgeMessage({ nativeEvent: { json: clientFrame({ type: 'ready' }) } })
|
||||
})
|
||||
// A reload within this mount must refresh storage again so intervening app writes reach init.
|
||||
expect(dependencies.storageRefreshes).toBe(2)
|
||||
// A document that reloads inside one mount asks again; a refresh per ask is what lets a key
|
||||
// the app changed meanwhile reach the `init` after it.
|
||||
expect(dependencies.storageRefreshes - refreshesBeforeReady).toBe(2)
|
||||
})
|
||||
|
||||
// Observe wire frames only; ruling 34 leaves delivery untracked and one-shot consumption to the page.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Linking, View } from 'react-native'
|
||||
import { useNavigation, useNavigationContainerRef, useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useNavigation, useNavigationContainerRef, useRouter } from 'expo-router'
|
||||
import {
|
||||
OrcaMobileWebShellView,
|
||||
parseMobileWebShellLoadState
|
||||
@@ -25,18 +25,21 @@ import { useNativeDeviceVerbs } from '../platform/use-native-device-verbs'
|
||||
import { useNativePdfViewer } from '../platform/native-pdf-view'
|
||||
import { useShellPageBack } from './use-shell-page-back'
|
||||
import { useShellStackPop } from './use-shell-stack-pop'
|
||||
import { usePageLocationOnFold } from './page-location-on-fold'
|
||||
import { useMobileWebShellSession } from './use-mobile-web-shell-session'
|
||||
import { useShellPageStorageRefresh } from './use-shell-page-storage-refresh'
|
||||
import { usePageHostSnapshot } from './use-page-host-snapshot'
|
||||
import { useReportedHostAreaServing } from './host-area-serving'
|
||||
import { isHostRouteOf } from './page-route-policy'
|
||||
import { nativeStackReturn } from './native-stack-return'
|
||||
import { usePageLocationOnFold } from './page-location-on-fold'
|
||||
import { useResponsiveLayout } from '../layout/responsive-layout'
|
||||
import { storageReadParamsSchema, type BridgeNativeVerb } from './bridge/bridge-native-verbs'
|
||||
import { SHELL_OPENING_LABEL, ShellPageCover } from './ShellWaitingFrame'
|
||||
import { ShellStateScreen } from './ShellStateScreen'
|
||||
import { useReportedShellOverlayLoad, type ShellOverlayLoad } from './shell-overlay-load'
|
||||
import { pageSafeAreaInsets, usePublishedSafeAreaInsets } from './page-safe-area-insets'
|
||||
import { ShellStateScreen } from './ShellStateScreen'
|
||||
import { usePageFoldDivision, usePublishedFoldDivision } from './page-fold-division'
|
||||
import { usePublishedShellKeyRail, useShellKeyRail } from './use-shell-key-rail'
|
||||
import { hostProfileUnreadableLine, pageFaultLine } from './shell-lifecycle-log'
|
||||
import { recordShellLifecycleLine } from './shell-lifecycle-line-recorder'
|
||||
|
||||
@@ -112,7 +115,13 @@ export function MobileWebShellScreen({
|
||||
updateNotice !== null && state.kind === 'ready' && noticeDismissedFor !== state.sessionId
|
||||
// Edge-to-edge disables adjustResize and zeroes WebView IME insets, so visualViewport never shrinks; publish native keyboard height to the page.
|
||||
const keyboardInset = Math.max(0, useKeyboardOcclusion())
|
||||
const pageInsets = pageSafeAreaInsets({ insets, topCovered: noticeShown })
|
||||
const pageFold = usePageFoldDivision()
|
||||
const pageInsets = pageSafeAreaInsets({
|
||||
insets: pageFold.safeAreaInsets,
|
||||
topCovered: noticeShown
|
||||
})
|
||||
const readySessionId = state.kind === 'ready' ? state.sessionId : null
|
||||
const keyRail = useShellKeyRail({ sessionId: readySessionId, overlay, routeGrants })
|
||||
const {
|
||||
snapshot,
|
||||
unreadable,
|
||||
@@ -122,9 +131,10 @@ export function MobileWebShellScreen({
|
||||
writeStorage,
|
||||
readWorkspaceKey
|
||||
} = usePageHostSnapshot(hostId, route.pathname, ownsHostArea)
|
||||
// Create the media registry before the bridge; its staged files must belong to this page session.
|
||||
const serveDeviceVerb = useNativeDeviceVerbs(state.kind === 'ready' ? state.sessionId : null)
|
||||
const servePdfView = useNativePdfViewer(hostId, state.kind === 'ready' ? state.sessionId : null)
|
||||
// Create the media registry before the bridge; staged files belong to this page session.
|
||||
const serveDeviceVerb = useNativeDeviceVerbs(readySessionId)
|
||||
const servePdfView = useNativePdfViewer(hostId, readySessionId)
|
||||
// Read the shell's storage for this host.
|
||||
const serveNativeVerb = (verb: BridgeNativeVerb, params: unknown): Promise<unknown> =>
|
||||
verb === 'native.storage.read'
|
||||
? readWorkspaceKey(storageReadParamsSchema.parse(params).key).then((value) => ({ value }))
|
||||
@@ -137,6 +147,7 @@ export function MobileWebShellScreen({
|
||||
console.warn('[web-shell] could not open a URL for the page', { url, error })
|
||||
})
|
||||
}
|
||||
|
||||
// Push so Back reveals the mounted page without downloading or handshaking again, unless the page replaced itself.
|
||||
const openPageRoute = (href: string, replace = false) => {
|
||||
if (overlay) {
|
||||
@@ -157,8 +168,8 @@ export function MobileWebShellScreen({
|
||||
})
|
||||
return
|
||||
}
|
||||
// The wide host route owns the session area; dismissTo pops to it or replaces after a cold deep link, avoiding a second copy.
|
||||
if (isWideLayout && isHostRouteOf(href, hostId)) {
|
||||
// Host exits preserve notice params and replace cold deep links at either width.
|
||||
if (isHostRouteOf(href, hostId)) {
|
||||
router.dismissTo(href)
|
||||
return
|
||||
}
|
||||
@@ -175,6 +186,9 @@ export function MobileWebShellScreen({
|
||||
route,
|
||||
safeAreaInsets: pageInsets,
|
||||
keyboardInset,
|
||||
pageGeometry: pageFold.geometry,
|
||||
foldDivision: pageFold.division,
|
||||
terminalKeyRail: keyRail.keys,
|
||||
colorScheme,
|
||||
uiLocale,
|
||||
pageRoutes,
|
||||
@@ -202,6 +216,7 @@ export function MobileWebShellScreen({
|
||||
onPagePainted: reportPagePainted,
|
||||
onPageBackClaim: reportPageBackClaim,
|
||||
onPageLocation: (href) => holdPageLocation(href, ownsHostArea),
|
||||
onTerminalKeyRailClaim: keyRail.reportClaim,
|
||||
onRouteParamClear: (param, value) => {
|
||||
onRouteParamClear?.(param, value)
|
||||
},
|
||||
@@ -221,17 +236,17 @@ export function MobileWebShellScreen({
|
||||
onBinaryFramesDropped: reportDroppedBinaryFrames
|
||||
})
|
||||
|
||||
// paneKey changes keep the session mounted; publish the new route without reloading. The longer-lived host deduplicates routes and preserves any in-flight frame.
|
||||
const publishRoute = bridge.publishRoute
|
||||
useEffect(() => {
|
||||
publishRoute(route)
|
||||
}, [publishRoute, route])
|
||||
// paneKey changes keep the session mounted; the host deduplicates routes and preserves in-flight frames.
|
||||
useShellPageStorageRefresh(refreshStorage, bridge.publishStorage)
|
||||
useEffect(() => bridge.publishRoute(route), [bridge.publishRoute, route])
|
||||
|
||||
usePublishedSafeAreaInsets(bridge.publishSafeAreaInsets, pageInsets)
|
||||
const publishKeyboardInset = bridge.publishKeyboardInset
|
||||
useEffect(() => {
|
||||
publishKeyboardInset(keyboardInset)
|
||||
}, [publishKeyboardInset, keyboardInset])
|
||||
usePublishedFoldDivision(bridge.publishFoldDivision, pageFold.division)
|
||||
usePublishedShellKeyRail(keyRail.keys, bridge, keyRail.inputOwner, keyRail.toolbar)
|
||||
|
||||
// Use navigation screen options to disable this stack entry’s gesture; the router has no such option.
|
||||
useShellPageBack({
|
||||
@@ -276,6 +291,8 @@ export function MobileWebShellScreen({
|
||||
}
|
||||
return (
|
||||
<View
|
||||
ref={pageFold.rootRef}
|
||||
onLayout={pageFold.onRootLayout}
|
||||
style={[
|
||||
styles.shellRoot,
|
||||
// The page pads system bars from init insets; a visible banner owns the status-bar strip.
|
||||
@@ -297,6 +314,7 @@ export function MobileWebShellScreen({
|
||||
ref={bridge.viewRef}
|
||||
// Overlays cannot cover the screen beneath; hide their opaque WebView until first paint.
|
||||
style={[styles.shellView, overlay && pageFrame !== 'painted' && styles.unpaintedOverlay]}
|
||||
onLayout={pageFold.onShellLayout}
|
||||
generationDirectory={state.generationDirectory}
|
||||
sessionId={state.sessionId}
|
||||
bridgeEnabled={bridge.bridgeEnabled}
|
||||
@@ -339,10 +357,6 @@ const styles = themedStyleSheet(() => ({
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
shellView: {
|
||||
flex: 1
|
||||
},
|
||||
unpaintedOverlay: {
|
||||
opacity: 0
|
||||
}
|
||||
shellView: { flex: 1 },
|
||||
unpaintedOverlay: { opacity: 0 }
|
||||
}))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import { ProtocolBlockScreen } from '../components/ProtocolBlockScreen'
|
||||
import { SideSafeArea } from '../layout/side-safe-area'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { themedStyleSheet } from '../theme/themed-styles'
|
||||
import { useMobileColorScheme } from '../theme/use-mobile-color-scheme'
|
||||
@@ -86,7 +85,8 @@ function Failed({
|
||||
)
|
||||
}
|
||||
|
||||
function StateView({
|
||||
// What the shell shows natively until its page is ready.
|
||||
export function ShellStateScreen({
|
||||
hostId,
|
||||
state,
|
||||
onRetry,
|
||||
@@ -140,15 +140,6 @@ function StateView({
|
||||
}
|
||||
}
|
||||
|
||||
// What the shell shows natively until the page is ready; only the page draws in a released Duo rail, so these pad it.
|
||||
export function ShellStateScreen(props: Parameters<typeof StateView>[0]) {
|
||||
return (
|
||||
<SideSafeArea>
|
||||
<StateView {...props} />
|
||||
</SideSafeArea>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = themedStyleSheet(() => ({
|
||||
centered: {
|
||||
flex: 1,
|
||||
|
||||
@@ -4,6 +4,9 @@ import { BRIDGE_BACK_FRAME } from './bridge/bridge-page-back'
|
||||
// Carry Back state across client-driven host rebuilds: the page remains mounted and is never told to claim again.
|
||||
export type BridgeSessionBack = {
|
||||
readonly claimed: boolean
|
||||
/** The negotiated storage keys the page's `ready` named; absent for a page that named none. */
|
||||
readonly storageKeys?: readonly string[]
|
||||
readonly terminalKeyRailClaim?: { readonly inputOwner?: string }
|
||||
}
|
||||
|
||||
// Drop claims when their document ends to avoid swallowing Back on an unrelated page. Host disposal alone transfers the session’s read state without reporting a dropped claim.
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import type {
|
||||
BridgeTerminalKeyId,
|
||||
BridgeTerminalToolbar,
|
||||
BridgeTerminalKeyRail
|
||||
} from './bridge/bridge-terminal-key'
|
||||
import type { BridgePageGeometry } from './bridge/bridge-page-geometry'
|
||||
import type { TerminalBacklogEnd, TerminalBacklogTimers } from './bridge-terminal-output-backlog'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { BridgeSessionBack } from './bridge-host-back'
|
||||
@@ -10,6 +16,7 @@ import type {
|
||||
} from './bridge/bridge-envelope'
|
||||
import type { BridgeClearableRouteParam } from './bridge/bridge-route-update'
|
||||
import type { BridgeSafeAreaInsets } from './bridge/bridge-safe-area-insets'
|
||||
import type { BridgeFoldDivision } from './bridge/bridge-fold-division'
|
||||
import type { BridgeHapticsKind } from './bridge/bridge-haptics-notify'
|
||||
import type { BridgeErrorCapture } from './bridge/bridge-error-capture'
|
||||
import type { BridgeNativeVerb } from './bridge/bridge-native-verbs'
|
||||
@@ -76,6 +83,13 @@ export type BridgeHostOptions = {
|
||||
keyboardInset?: number
|
||||
// Default to dark; publishColorScheme moves the app's resolved scheme later.
|
||||
colorScheme?: BridgeColorScheme
|
||||
/** The fold splitting the page on a partially folded device. None when absent; moved later with
|
||||
* `publishFoldDivision`. */
|
||||
pageGeometry?: BridgePageGeometry
|
||||
foldDivision?: BridgeFoldDivision | null
|
||||
/** The keys the shell's rail shows beside the page. None when absent; moved later with
|
||||
* `publishTerminalKeyRail`. */
|
||||
terminalKeyRail?: BridgeTerminalKeyRail
|
||||
// Absent until the shell knows its language; publishUiLocale moves it later.
|
||||
uiLocale?: BridgeUiLocale
|
||||
// Read identity at forward time because this host outlives renders; replace the page placeholder as init promised or the desktop refuses spoofed sends.
|
||||
@@ -100,9 +114,20 @@ export type BridgeHostOptions = {
|
||||
onPagePainted: () => void
|
||||
// Required for native interception: only a live claim owns Back. Document-ending paths publish false so callers need not infer staleness.
|
||||
onPageBackClaim: (claimed: boolean) => void
|
||||
// Where a host-area page navigated inside its own document; the native route never moves for those hops.
|
||||
// Where a host-area page from an older desktop navigated inside its own document; the native route never moves for those hops.
|
||||
onPageLocation: (href: string) => void
|
||||
// Clear a consumed one-shot param only if its value still matches; a newer tap may already have replaced it (ruling 34).
|
||||
/** The page shows a terminal with its key row, or stopped; false also arrives each time a
|
||||
* document starts or ends, and the page claims again on the `init` that follows. */
|
||||
onTerminalKeyRailClaim?: (
|
||||
claimed: boolean,
|
||||
inputOwner?: string,
|
||||
toolbar?: BridgeTerminalToolbar
|
||||
) => void
|
||||
/**
|
||||
* The page applied a one-shot route param and is asking for it to be erased (ruling 34), naming
|
||||
* the value it applied. The holder of that param compares before it clears: a tap that has moved
|
||||
* on since leaves a newer value here, and a clear naming the older one is not for it.
|
||||
*/
|
||||
onRouteParamClear: (param: BridgeClearableRouteParam, value: string) => void
|
||||
// Escalate invalid shell routes visibly; a page-only console warning would leave a blank document retrying forever.
|
||||
onRouteRefused: (issue: string) => void
|
||||
@@ -112,3 +137,32 @@ export type BridgeHostOptions = {
|
||||
// Inject timers for tests so they exercise the shipped silence bound without waiting twenty seconds per case.
|
||||
terminalTimers?: TerminalBacklogTimers
|
||||
}
|
||||
|
||||
export type BridgeHost = {
|
||||
publishStorage: () => void
|
||||
receive: (json: string) => void
|
||||
// Hold same-screen param updates even when posting fails; repair from the next ready without delivery tracking. Different pathnames require remount; the page clears consumed params (rulings 33.1/34).
|
||||
publishRoute: (next: BridgeInitRoute) => void
|
||||
publishSafeAreaInsets: (next: BridgeSafeAreaInsets) => void
|
||||
publishKeyboardInset: (next: number) => void
|
||||
/** Hands this session a moved fold over the same re-sent `init`. */
|
||||
publishPageGeometry: (next: BridgePageGeometry) => void
|
||||
publishFoldDivision: (next: BridgeFoldDivision | null) => void
|
||||
/** Tells this session the shell's key rail shows or hides, over the same re-sent `init`. */
|
||||
publishTerminalKeyRail: (next: BridgeTerminalKeyRail) => void
|
||||
publishColorScheme: (next: BridgeColorScheme) => void
|
||||
publishUiLocale: (next: BridgeUiLocale) => void
|
||||
/**
|
||||
* Hands the page one press on the key rail. False when this session was not granted the rail,
|
||||
* which is every page older than it, or when no document holds the view.
|
||||
*/
|
||||
sendTerminalKey: (key: BridgeTerminalKeyId, inputOwner?: string) => boolean
|
||||
/**
|
||||
* Hands the page one Back press. False when this document never said it takes one, which is
|
||||
* every page older than the frame; the caller then leaves the key to the navigator.
|
||||
*/
|
||||
sendBack: () => boolean
|
||||
// Transfer the session’s Back state on client-driven host rebuild; the mounted page never reclaims it. Ending sessions simply discard it.
|
||||
readSessionBack: () => BridgeSessionBack
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BridgeTerminalToolbar } from './bridge/bridge-terminal-key'
|
||||
import { readBridgeExternalLinkUrl } from './bridge/bridge-caps'
|
||||
import {
|
||||
BRIDGE_EXTERNAL_LINK_GRANT,
|
||||
@@ -13,6 +14,7 @@ import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge/bridge-page-back'
|
||||
import { BRIDGE_PAGE_LOCATION } from './bridge/bridge-page-location'
|
||||
import { BRIDGE_PAGE_PAINTED } from './bridge/bridge-page-painted'
|
||||
import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge/bridge-route-update'
|
||||
import { BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY } from './bridge/bridge-terminal-key'
|
||||
import type { BridgeHostOptions } from './bridge-host-contract'
|
||||
import { pageMayWriteStorageKey } from './page-storage-keys'
|
||||
|
||||
@@ -25,6 +27,11 @@ export function createBridgeNotifyForwarder(args: {
|
||||
initSent: () => boolean
|
||||
route: () => BridgeInitRoute | null
|
||||
onBackClaim: (claimed: boolean) => void
|
||||
onTerminalKeyRailClaim: (
|
||||
claimed: boolean,
|
||||
inputOwner?: string,
|
||||
toolbar?: BridgeTerminalToolbar
|
||||
) => void
|
||||
}): (message: NotifyMessage) => void {
|
||||
const { options, granted } = args
|
||||
const { client, host } = options
|
||||
@@ -48,6 +55,10 @@ export function createBridgeNotifyForwarder(args: {
|
||||
args.onBackClaim(message.claimed)
|
||||
return
|
||||
}
|
||||
if (message.name === BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY) {
|
||||
args.onTerminalKeyRailClaim(message.claimed, message.inputOwner, message.toolbar)
|
||||
return
|
||||
}
|
||||
if (message.name === 'foreground') {
|
||||
if (message.reason === undefined) {
|
||||
client.notifyForeground()
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
EMPTY_PAGE_GEOMETRY,
|
||||
samePageGeometry,
|
||||
type BridgePageGeometry
|
||||
} from './bridge/bridge-page-geometry'
|
||||
import {
|
||||
BridgeInitRouteSchema,
|
||||
type BridgeColorScheme,
|
||||
@@ -10,6 +15,8 @@ import {
|
||||
ZERO_SAFE_AREA_INSETS,
|
||||
type BridgeSafeAreaInsets
|
||||
} from './bridge/bridge-safe-area-insets'
|
||||
import { sameFoldDivision, type BridgeFoldDivision } from './bridge/bridge-fold-division'
|
||||
import { sameTerminalKeyRail, type BridgeTerminalKeyRail } from './bridge/bridge-terminal-key'
|
||||
|
||||
export type BridgeHostRoute = {
|
||||
readonly current: () => BridgeInitRoute | null
|
||||
@@ -21,6 +28,16 @@ export type BridgeHostRoute = {
|
||||
readonly publishSafeAreaInsets: (next: BridgeSafeAreaInsets, deliverable: boolean) => void
|
||||
readonly keyboardInset: () => number
|
||||
readonly publishKeyboardInset: (next: number, deliverable: boolean) => void
|
||||
/** The fold the next `init` carries; null when none splits the page. */
|
||||
readonly pageGeometry: () => BridgePageGeometry
|
||||
readonly publishPageGeometry: (next: BridgePageGeometry, deliverable: boolean) => void
|
||||
readonly foldDivision: () => BridgeFoldDivision | null
|
||||
/** Moves the held fold on the same lane as the insets. */
|
||||
readonly publishFoldDivision: (next: BridgeFoldDivision | null, deliverable: boolean) => void
|
||||
/** The keys the next `init` tells the page the shell's rail shows beside it. */
|
||||
readonly terminalKeyRail: () => BridgeTerminalKeyRail
|
||||
/** Moves the rail's keys on the same lane as the insets. */
|
||||
readonly publishTerminalKeyRail: (next: BridgeTerminalKeyRail, deliverable: boolean) => void
|
||||
readonly colorScheme: () => BridgeColorScheme
|
||||
readonly publishColorScheme: (next: BridgeColorScheme, deliverable: boolean) => void
|
||||
readonly uiLocale: () => BridgeUiLocale | undefined
|
||||
@@ -36,6 +53,9 @@ export function createBridgeHostRoute(args: {
|
||||
onRefused: (issue: string) => void
|
||||
safeAreaInsets?: BridgeSafeAreaInsets
|
||||
keyboardInset?: number
|
||||
pageGeometry?: BridgePageGeometry
|
||||
foldDivision?: BridgeFoldDivision | null
|
||||
terminalKeyRail?: BridgeTerminalKeyRail
|
||||
colorScheme?: BridgeColorScheme
|
||||
uiLocale?: BridgeUiLocale
|
||||
}): BridgeHostRoute {
|
||||
@@ -43,6 +63,9 @@ export function createBridgeHostRoute(args: {
|
||||
let route = parsed.success && !args.refused ? parsed.data : null
|
||||
let insets = args.safeAreaInsets ?? ZERO_SAFE_AREA_INSETS
|
||||
let keyboard = args.keyboardInset ?? 0
|
||||
let geometry = args.pageGeometry ?? EMPTY_PAGE_GEOMETRY
|
||||
let fold = args.foldDivision ?? null
|
||||
let rail = args.terminalKeyRail ?? []
|
||||
let scheme = args.colorScheme ?? 'dark'
|
||||
let uiLocale = args.uiLocale
|
||||
return {
|
||||
@@ -79,6 +102,36 @@ export function createBridgeHostRoute(args: {
|
||||
args.sendInit()
|
||||
}
|
||||
},
|
||||
pageGeometry: () => geometry,
|
||||
publishPageGeometry: (next, deliverable) => {
|
||||
if (samePageGeometry(geometry, next)) {
|
||||
return
|
||||
}
|
||||
geometry = next
|
||||
if (deliverable) {
|
||||
args.sendInit()
|
||||
}
|
||||
},
|
||||
foldDivision: () => fold,
|
||||
publishFoldDivision: (next, deliverable) => {
|
||||
if (sameFoldDivision(fold, next)) {
|
||||
return
|
||||
}
|
||||
fold = next
|
||||
if (deliverable) {
|
||||
args.sendInit()
|
||||
}
|
||||
},
|
||||
terminalKeyRail: () => rail,
|
||||
publishTerminalKeyRail: (next, deliverable) => {
|
||||
if (sameTerminalKeyRail(rail, next)) {
|
||||
return
|
||||
}
|
||||
rail = next
|
||||
if (deliverable) {
|
||||
args.sendInit()
|
||||
}
|
||||
},
|
||||
colorScheme: () => scheme,
|
||||
publishColorScheme: (next, deliverable) => {
|
||||
if (scheme === next) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { clientFrame } from './bridge-host-test-fakes'
|
||||
import { harness } from './bridge-host-test-harness'
|
||||
import {
|
||||
BRIDGE_TERMINAL_KEY_FRAME,
|
||||
BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY
|
||||
} from './bridge/bridge-terminal-key'
|
||||
|
||||
function claim(claimed: boolean, inputOwner?: string): string {
|
||||
return clientFrame({
|
||||
type: 'notify',
|
||||
name: BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
|
||||
claimed,
|
||||
inputOwner
|
||||
})
|
||||
}
|
||||
|
||||
describe('a terminal key rail claim across host rebuilds', () => {
|
||||
it('delivers the first key for the established owner without another ready or storage refresh', () => {
|
||||
const first = harness({ ready: true })
|
||||
first.host.receive(claim(true, 'pane-a'))
|
||||
expect(first.host.sendTerminalKey('arrowUp', 'pane-a')).toBe(true)
|
||||
first.host.dispose()
|
||||
|
||||
const rebuilt = harness({
|
||||
sessionEstablished: true,
|
||||
sessionBack: first.host.readSessionBack()
|
||||
})
|
||||
expect(rebuilt.host.sendTerminalKey('arrowUp', 'pane-a')).toBe(true)
|
||||
expect(rebuilt.last()).toEqual({
|
||||
v: 1,
|
||||
type: BRIDGE_TERMINAL_KEY_FRAME,
|
||||
key: 'arrowUp',
|
||||
inputOwner: 'pane-a'
|
||||
})
|
||||
expect(rebuilt.host.sendTerminalKey('arrowUp', 'pane-b')).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['ready', 'close'] as const)(
|
||||
'drops the carried claim on %s before a new owner takes it',
|
||||
(type) => {
|
||||
const first = harness({ ready: true })
|
||||
first.host.receive(claim(true, 'pane-a'))
|
||||
const rebuilt = harness({
|
||||
sessionEstablished: true,
|
||||
sessionBack: first.host.readSessionBack()
|
||||
})
|
||||
rebuilt.host.receive(clientFrame({ type }))
|
||||
expect(rebuilt.host.sendTerminalKey('arrowUp', 'pane-a')).toBe(false)
|
||||
expect(rebuilt.host.readSessionBack()).not.toHaveProperty('terminalKeyRailClaim')
|
||||
|
||||
if (type === 'close') {
|
||||
rebuilt.host.receive(clientFrame({ type: 'ready' }))
|
||||
}
|
||||
rebuilt.host.receive(claim(true, 'pane-b'))
|
||||
expect(rebuilt.host.sendTerminalKey('enter', 'pane-a')).toBe(false)
|
||||
expect(rebuilt.host.sendTerminalKey('enter', 'pane-b')).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not carry a rail the page has explicitly released', () => {
|
||||
const first = harness({ ready: true })
|
||||
first.host.receive(claim(true, 'pane-a'))
|
||||
first.host.receive(claim(false))
|
||||
const snapshot = first.host.readSessionBack()
|
||||
expect(snapshot).not.toHaveProperty('terminalKeyRailClaim')
|
||||
const rebuilt = harness({ sessionEstablished: true, sessionBack: snapshot })
|
||||
expect(rebuilt.host.sendTerminalKey('enter', 'pane-a')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -10,37 +10,20 @@ import {
|
||||
BRIDGE_RPC_ENVELOPE_GRANT,
|
||||
readBridgeClientMessage,
|
||||
type BridgeClientMessage,
|
||||
type BridgeColorScheme,
|
||||
type BridgeUiLocale,
|
||||
type BridgeConnectionSnapshot,
|
||||
type BridgeInitRoute
|
||||
type BridgeConnectionSnapshot
|
||||
} from './bridge/bridge-envelope'
|
||||
import { BridgePageRouteGrantsSchema } from './bridge/bridge-page-route-grants'
|
||||
import { createBridgeInitFrame } from './bridge/bridge-init-frame'
|
||||
import { splitBridgeReply } from './bridge/bridge-reply-chunking'
|
||||
import { createBridgeHostFrames } from './bridge-host-frames'
|
||||
import { createBridgeHostBack, type BridgeSessionBack } from './bridge-host-back'
|
||||
import { createBridgeHostBack } from './bridge-host-back'
|
||||
import { createBridgeNotifyForwarder } from './bridge-host-notify'
|
||||
import { createBridgeHostRoute } from './bridge-host-route'
|
||||
import type { BridgeSafeAreaInsets } from './bridge/bridge-safe-area-insets'
|
||||
import type { BridgeHostOptions } from './bridge-host-contract'
|
||||
import { pageStorageForReader } from './page-storage-keys'
|
||||
import { BRIDGE_TERMINAL_KEY_FRAME } from './bridge/bridge-terminal-key'
|
||||
import type { BridgeHost, BridgeHostOptions } from './bridge-host-contract'
|
||||
|
||||
export type { BridgeHostDiagnostic, BridgeHostOptions } from './bridge-host-contract'
|
||||
|
||||
export type BridgeHost = {
|
||||
receive: (json: string) => void
|
||||
// Hold same-screen param updates even when posting fails; repair from the next ready without delivery tracking. Different pathnames require remount; the page clears consumed params (rulings 33.1/34).
|
||||
publishRoute: (next: BridgeInitRoute) => void
|
||||
publishSafeAreaInsets: (next: BridgeSafeAreaInsets) => void
|
||||
publishKeyboardInset: (next: number) => void
|
||||
publishColorScheme: (next: BridgeColorScheme) => void
|
||||
publishUiLocale: (next: BridgeUiLocale) => void
|
||||
// False leaves Back to native navigation, including pages predating this frame.
|
||||
sendBack: () => boolean
|
||||
// Transfer the session’s Back state on client-driven host rebuild; the mounted page never reclaims it. Ending sessions simply discard it.
|
||||
readSessionBack: () => BridgeSessionBack
|
||||
dispose: () => void
|
||||
}
|
||||
export type { BridgeHost, BridgeHostDiagnostic, BridgeHostOptions } from './bridge-host-contract'
|
||||
|
||||
// Bind the host structurally to one RPC client; the protocol lets pages name no other host. Native origin checks reject expired sessions, and init caps are enforced here.
|
||||
export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
@@ -68,6 +51,9 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
onRefused: (issue) => options.onDiagnostic?.({ kind: 'route-update-refused', issue }),
|
||||
...(options.safeAreaInsets === undefined ? {} : { safeAreaInsets: options.safeAreaInsets }),
|
||||
...(options.keyboardInset === undefined ? {} : { keyboardInset: options.keyboardInset }),
|
||||
...(options.pageGeometry === undefined ? {} : { pageGeometry: options.pageGeometry }),
|
||||
...(options.foldDivision === undefined ? {} : { foldDivision: options.foldDivision }),
|
||||
...(options.terminalKeyRail === undefined ? {} : { terminalKeyRail: options.terminalKeyRail }),
|
||||
...(options.colorScheme === undefined ? {} : { colorScheme: options.colorScheme }),
|
||||
...(options.uiLocale === undefined ? {} : { uiLocale: options.uiLocale })
|
||||
})
|
||||
@@ -76,6 +62,9 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
let serving = true
|
||||
// serving initially admits the first ready batch, but only an established session holds grants. Inherit establishment when rebuilding beneath a live page.
|
||||
let initSent = options.sessionEstablished
|
||||
// What the page's init reader accepts, carried across a rebuild because the page does not
|
||||
// re-handshake. Null is a page older than `storageKeys`, which gets the legacy keys only.
|
||||
let pageStorageKeys: readonly string[] | null = options.sessionBack?.storageKeys ?? null
|
||||
|
||||
const frames = createBridgeHostFrames({
|
||||
post: options.post,
|
||||
@@ -108,11 +97,25 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
}
|
||||
}
|
||||
|
||||
// Build init synchronously for first-render/golden timing; reread route, client state and storage on every ready, including reloads after writes.
|
||||
// Refused routes send nothing; failed posts produce one diagnostic with no held frame or retry. The page’s next ready repairs state (ruling 34).
|
||||
function sendInit(): void {
|
||||
/**
|
||||
* Posts `init`, and answers nothing (ruling 34).
|
||||
*
|
||||
* Sent every time it is asked for, with the keys read every time it is sent. A page that saw a
|
||||
* `state` older than the one it holds recovers by asking again rather than by living with a
|
||||
* cache it knows is wrong, and the same is true of its storage: a document that reloads inside
|
||||
* one mount — which the fault path produces — would otherwise be primed from before its own
|
||||
* writes, and `publishPageStorage` clears the page's cache to match.
|
||||
*
|
||||
* The frame is built synchronously, because the page refuses every member until `init` lands and
|
||||
* the golden recorder mounts its screen in the same turn it drains one; a frame whose contents
|
||||
* waited on a promise would change what the first render of every replay sees.
|
||||
*
|
||||
* A refused route sends nothing at all, and a post the view would not take is one diagnostic and
|
||||
* no further attempt: nothing here holds a frame, and nothing retries one.
|
||||
*/
|
||||
function sendInit(refreshTerminalShortcuts = false): void {
|
||||
const route = routes.current()
|
||||
if (route === null) {
|
||||
if (route === null || (refreshTerminalShortcuts && !deliverable())) {
|
||||
return
|
||||
}
|
||||
initSent = true
|
||||
@@ -125,6 +128,9 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
route,
|
||||
safeAreaInsets: routes.safeAreaInsets(),
|
||||
keyboardInset: routes.keyboardInset(),
|
||||
pageGeometry: routes.pageGeometry(),
|
||||
foldDivision: routes.foldDivision(),
|
||||
terminalKeyRail: routes.terminalKeyRail(),
|
||||
colorScheme: routes.colorScheme(),
|
||||
uiLocale: routes.uiLocale(),
|
||||
pageRoutes,
|
||||
@@ -134,7 +140,8 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
granted,
|
||||
host,
|
||||
ownsHostArea: options.ownsHostArea === true,
|
||||
...options.readStorage()
|
||||
...pageStorageForReader(options.readStorage(), pageStorageKeys),
|
||||
refreshTerminalShortcuts
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -183,12 +190,21 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
...(options.sessionBack === undefined ? {} : { established: options.sessionBack })
|
||||
})
|
||||
|
||||
let railOwner = options.sessionBack?.terminalKeyRailClaim?.inputOwner
|
||||
let railClaimed = options.sessionBack?.terminalKeyRailClaim !== undefined
|
||||
type RailClaim = NonNullable<BridgeHostOptions['onTerminalKeyRailClaim']>
|
||||
const reportRailClaim: RailClaim = (claimed, inputOwner, toolbar) => {
|
||||
railClaimed = claimed
|
||||
railOwner = claimed ? inputOwner : undefined
|
||||
options.onTerminalKeyRailClaim?.(claimed, inputOwner, toolbar)
|
||||
}
|
||||
const forwardNotify = createBridgeNotifyForwarder({
|
||||
options,
|
||||
granted,
|
||||
initSent: () => initSent,
|
||||
route: () => routes.current(),
|
||||
onBackClaim: back.readClaim
|
||||
onBackClaim: back.readClaim,
|
||||
onTerminalKeyRailClaim: reportRailClaim
|
||||
})
|
||||
|
||||
function settleAll(notify: boolean): void {
|
||||
@@ -209,8 +225,13 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
// Every ready claims the view, whether a new document or a repeated ask.
|
||||
if (message.type === 'ready') {
|
||||
serving = true
|
||||
pageStorageKeys = message.storageKeys ?? null
|
||||
back.readReady()
|
||||
// Answer every ready with current state; this repairs missed frames without host retries (ruling 34).
|
||||
// Dropped like the Back claim: a page still showing a terminal claims again on this `init`.
|
||||
reportRailClaim(false)
|
||||
// Every time it is asked, not once: the page re-asks on a backoff, and each ask is answered
|
||||
// with the route the shell holds now. That is the whole repair path for a frame that never
|
||||
// arrived (ruling 34) — nothing here waits on one, and nothing retries one.
|
||||
sendInit()
|
||||
options.onPageReady()
|
||||
return
|
||||
@@ -251,6 +272,7 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
settleAll(false)
|
||||
serving = false
|
||||
back.drop()
|
||||
reportRailClaim(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -279,14 +301,26 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
}
|
||||
dispatch(read.message)
|
||||
},
|
||||
publishRoute: (next) => {
|
||||
routes.publish(next, serving && initSent)
|
||||
},
|
||||
publishRoute: (next) => routes.publish(next, serving && initSent),
|
||||
publishSafeAreaInsets: (next) => {
|
||||
routes.publishSafeAreaInsets(next, deliverable())
|
||||
},
|
||||
publishKeyboardInset: (next) => {
|
||||
routes.publishKeyboardInset(next, deliverable())
|
||||
publishKeyboardInset: (next) => routes.publishKeyboardInset(next, deliverable()),
|
||||
publishStorage: () => sendInit(true),
|
||||
publishPageGeometry: (next) => routes.publishPageGeometry(next, deliverable()),
|
||||
publishFoldDivision: (next) => routes.publishFoldDivision(next, deliverable()),
|
||||
publishTerminalKeyRail: (next) => routes.publishTerminalKeyRail(next, deliverable()),
|
||||
sendTerminalKey: (key, inputOwner) => {
|
||||
if (!railClaimed || inputOwner !== railOwner || !deliverable()) {
|
||||
return false
|
||||
}
|
||||
send({
|
||||
v: BRIDGE_PROTOCOL_VERSION,
|
||||
type: BRIDGE_TERMINAL_KEY_FRAME,
|
||||
key,
|
||||
inputOwner
|
||||
})
|
||||
return true
|
||||
},
|
||||
publishColorScheme: (next) => {
|
||||
routes.publishColorScheme(next, deliverable())
|
||||
@@ -295,7 +329,11 @@ export function createBridgeHost(options: BridgeHostOptions): BridgeHost {
|
||||
routes.publishUiLocale(next, deliverable())
|
||||
},
|
||||
sendBack: back.send,
|
||||
readSessionBack: back.read,
|
||||
readSessionBack: () => ({
|
||||
...back.read(),
|
||||
...(pageStorageKeys === null ? {} : { storageKeys: pageStorageKeys }),
|
||||
...(railClaimed ? { terminalKeyRailClaim: { inputOwner: railOwner } } : {})
|
||||
}),
|
||||
dispose
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('a Back press crossing from the shell to the page', () => {
|
||||
expect(pair.host.sendBack()).toBe(true)
|
||||
await pair.flush()
|
||||
expect(took).toHaveBeenCalledTimes(1)
|
||||
expect(pair.readToShell().find((frame) => frame.type === 'ready')).toEqual({
|
||||
expect(pair.readToShell().find((frame) => frame.type === 'ready')).toMatchObject({
|
||||
v: 1,
|
||||
type: 'ready'
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { utf8ByteLength } from './bridge-caps'
|
||||
import type { BridgeTerminalKeyId } from './bridge-terminal-key'
|
||||
import { BridgeReplyRefusedError } from './bridge-client-errors'
|
||||
import type { BridgeRpcClientDiagnostic } from './bridge-client-diagnostics'
|
||||
import type { BridgeClientRequests } from './bridge-client-requests'
|
||||
@@ -18,6 +19,7 @@ export type BridgeInboundFramePort = {
|
||||
acceptInit: (message: Extract<BridgeHostMessage, { type: 'init' }>) => void
|
||||
acceptState: (connection: BridgeConnectionSnapshot) => void
|
||||
acceptBack: () => void
|
||||
acceptTerminalKey: (key: BridgeTerminalKeyId, inputOwner?: string) => void
|
||||
}
|
||||
|
||||
function describeStreamFailure(error: unknown): string {
|
||||
@@ -60,6 +62,9 @@ export function createBridgeInboundFrameReader(
|
||||
case 'back':
|
||||
port.acceptBack()
|
||||
return
|
||||
case 'terminalKey':
|
||||
port.acceptTerminalKey(message.key, message.inputOwner)
|
||||
return
|
||||
case 'reply':
|
||||
if (!requests.has(message.id)) {
|
||||
report({ kind: 'unknown-id' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BRIDGE_PROTOCOL_VERSION, type BridgeClientMessage } from './bridge-envelope'
|
||||
import { NEGOTIATED_PAGE_STORAGE_KEYS } from '../page-storage-keys'
|
||||
|
||||
export const BRIDGE_READY_RETRY_MIN_MS = 50
|
||||
export const BRIDGE_READY_RETRY_MAX_MS = 2000
|
||||
@@ -41,5 +42,9 @@ export function createBridgeInitHandshake(ask: () => void): BridgeInitHandshake
|
||||
}
|
||||
|
||||
export function createPageReadyFrame(): Extract<BridgeClientMessage, { type: 'ready' }> {
|
||||
return { v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }
|
||||
return {
|
||||
v: BRIDGE_PROTOCOL_VERSION,
|
||||
type: 'ready',
|
||||
storageKeys: [...NEGOTIATED_PAGE_STORAGE_KEYS]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { BridgeTerminalToolbar } from './bridge-terminal-key'
|
||||
import type { ForegroundNudgeReason } from '../../transport/types'
|
||||
import {
|
||||
BRIDGE_EXTERNAL_LINK_GRANT,
|
||||
@@ -15,8 +16,11 @@ import {
|
||||
import { captureBridgeError } from './bridge-error-capture'
|
||||
import { BRIDGE_LIVENESS_PROBE_NOTIFY } from './bridge-liveness-probe-notify'
|
||||
import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back'
|
||||
import { BRIDGE_PAGE_LOCATION } from './bridge-page-location'
|
||||
import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted'
|
||||
import {
|
||||
BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
|
||||
BRIDGE_TERMINAL_KEY_RAIL_GRANT
|
||||
} from './bridge-terminal-key'
|
||||
|
||||
// Ungated calls before init expose mount-order bugs; calls after close stay inert for teardown compatibility. Gated calls return false when no session/grant exists.
|
||||
// Fault reporting must never throw from its error-boundary caller; it reads the session without requiring one.
|
||||
@@ -42,7 +46,11 @@ export type BridgeClientNotifications = {
|
||||
notifyPageFault: (error: unknown) => boolean
|
||||
notifyPagePainted: () => void
|
||||
notifyBackClaim: (claimed: boolean) => void
|
||||
notifyPageLocation: (href: string) => void
|
||||
notifyTerminalKeyRailClaim: (
|
||||
claimed: boolean,
|
||||
inputOwner?: string,
|
||||
toolbar?: BridgeTerminalToolbar
|
||||
) => void
|
||||
}
|
||||
|
||||
export function createBridgeClientNotifications(
|
||||
@@ -131,8 +139,18 @@ export function createBridgeClientNotifications(
|
||||
notifyBackClaim: (claimed) => {
|
||||
post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_BACK_CLAIM_NOTIFY, claimed })
|
||||
},
|
||||
notifyPageLocation: (href) => {
|
||||
post({ v: BRIDGE_PROTOCOL_VERSION, type: 'notify', name: BRIDGE_PAGE_LOCATION, href })
|
||||
// Only to a shell that granted the rail: any other refuses the name, and has no rail to draw.
|
||||
notifyTerminalKeyRailClaim: (claimed, inputOwner, toolbar) => {
|
||||
if (deps.hasGrant(BRIDGE_TERMINAL_KEY_RAIL_GRANT)) {
|
||||
post({
|
||||
v: BRIDGE_PROTOCOL_VERSION,
|
||||
type: 'notify',
|
||||
name: BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
|
||||
...(inputOwner === undefined ? {} : { inputOwner }),
|
||||
...(toolbar === undefined ? {} : { toolbar }),
|
||||
claimed
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
BridgeUiLocale
|
||||
} from './bridge-envelope'
|
||||
import { ZERO_SAFE_AREA_INSETS, type BridgeSafeAreaInsets } from './bridge-safe-area-insets'
|
||||
import { EMPTY_PAGE_GEOMETRY, type BridgePageGeometry } from './bridge-page-geometry'
|
||||
import type { BridgeFoldDivision } from './bridge-fold-division'
|
||||
import type { BridgeTerminalKeyRail } from './bridge-terminal-key'
|
||||
|
||||
export type BridgeShellSession = {
|
||||
sessionId: string
|
||||
@@ -20,8 +23,15 @@ export type BridgeShellSession = {
|
||||
keyboardInset: number
|
||||
// Older shells omit this and paint dark native chrome, so absent means dark.
|
||||
colorScheme: BridgeColorScheme
|
||||
/** The fold splitting the page on a partially folded device; null when none does, which is
|
||||
* every device that does not fold and every shell before the field. */
|
||||
pageGeometry: BridgePageGeometry
|
||||
foldDivision: BridgeFoldDivision | null
|
||||
/** The keys the shell's rail shows beside this page; empty for every shell before the field. */
|
||||
terminalKeyRail: BridgeTerminalKeyRail
|
||||
// Null means an older shell sent no language; the page then follows the WebView's languages.
|
||||
uiLocale: BridgeUiLocale | null
|
||||
/** Null for a shell too old to name it; the page's own `loadHosts()` then answers with nothing. */
|
||||
host: BridgeInitHost | null
|
||||
storage: Readonly<Record<string, string>>
|
||||
// Oversize keys are withheld from storage and reject writes locally; older shells omit this additive field (ruling 33.6).
|
||||
@@ -42,6 +52,9 @@ export function readShellSession(
|
||||
pageRouteGrants: message.pageRouteGrants ?? null,
|
||||
safeAreaInsets: message.safeAreaInsets ?? ZERO_SAFE_AREA_INSETS,
|
||||
keyboardInset: message.keyboardInset ?? 0,
|
||||
pageGeometry: message.pageGeometry ?? EMPTY_PAGE_GEOMETRY,
|
||||
foldDivision: message.foldDivision ?? null,
|
||||
terminalKeyRail: message.terminalKeyRail ?? [],
|
||||
colorScheme: message.colorScheme ?? 'dark',
|
||||
uiLocale: message.uiLocale ?? null,
|
||||
host: message.host ?? null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TERMINAL_SHORTCUT_STORAGE_KEYS } from '../page-storage-keys'
|
||||
import { readShellSession, type BridgeShellSession } from './bridge-client-session'
|
||||
import { bridgeRouteMoved } from './bridge-route-update'
|
||||
import {
|
||||
@@ -5,6 +6,13 @@ import {
|
||||
ZERO_SAFE_AREA_INSETS,
|
||||
type BridgeSafeAreaInsets
|
||||
} from './bridge-safe-area-insets'
|
||||
import {
|
||||
EMPTY_PAGE_GEOMETRY,
|
||||
samePageGeometry,
|
||||
type BridgePageGeometry
|
||||
} from './bridge-page-geometry'
|
||||
import { sameFoldDivision, type BridgeFoldDivision } from './bridge-fold-division'
|
||||
import { sameTerminalKeyRail, type BridgeTerminalKeyRail } from './bridge-terminal-key'
|
||||
import type {
|
||||
BridgeColorScheme,
|
||||
BridgeConnectionSnapshot,
|
||||
@@ -20,23 +28,77 @@ export type BridgeClientShellSession = {
|
||||
current: () => BridgeShellSession | null
|
||||
accept: (message: InitMessage) => void
|
||||
onReady: (listener: () => void) => () => void
|
||||
/**
|
||||
* Fires each time the shell rewrites a param of the screen this page is already on, which is a
|
||||
* second `init` for the session it already holds. Never for the first one, and never for a
|
||||
* re-sent `init` whose route is the one the page already holds — a re-asked `ready` is answered
|
||||
* with that route and is not a request.
|
||||
*
|
||||
* One delivery per tap rather than one per distinct value: the shell clears the param after each
|
||||
* delivery, so a notification tap for the pane already showing arrives as a real move and a
|
||||
* listener that deduplicated by value would lose exactly that one.
|
||||
*/
|
||||
onStorageUpdate: (listener: () => void) => () => void
|
||||
onRouteUpdate: (listener: (route: BridgeInitRoute | null) => void) => () => void
|
||||
onSafeAreaInsetsUpdate: (listener: (insets: BridgeSafeAreaInsets) => void) => () => void
|
||||
onKeyboardInsetUpdate: (listener: (height: number) => void) => () => void
|
||||
/** Fires when an `init` moved the fold the page holds, including a replacement's. */
|
||||
onPageGeometryUpdate: (listener: (geometry: BridgePageGeometry) => void) => () => void
|
||||
onFoldDivisionUpdate: (listener: (division: BridgeFoldDivision | null) => void) => () => void
|
||||
/** Fires when an `init` moved the keys the shell's rail shows, including a replacement's. */
|
||||
onTerminalKeyRailUpdate: (listener: (keys: BridgeTerminalKeyRail) => void) => () => void
|
||||
onColorSchemeUpdate: (listener: (scheme: BridgeColorScheme) => void) => () => void
|
||||
onUiLocaleUpdate: (listener: (locale: BridgeUiLocale | null) => void) => () => void
|
||||
/** Drops the session and every listener waiting on one, which `close` is the only caller of. */
|
||||
close: () => void
|
||||
}
|
||||
|
||||
/** The session's listeners, which the page client hands on as its own members. */
|
||||
export type BridgeShellSessionListeners = Pick<
|
||||
BridgeClientShellSession,
|
||||
| 'onReady'
|
||||
| 'onRouteUpdate'
|
||||
| 'onStorageUpdate'
|
||||
| 'onSafeAreaInsetsUpdate'
|
||||
| 'onKeyboardInsetUpdate'
|
||||
| 'onColorSchemeUpdate'
|
||||
| 'onUiLocaleUpdate'
|
||||
| 'onPageGeometryUpdate'
|
||||
| 'onFoldDivisionUpdate'
|
||||
| 'onTerminalKeyRailUpdate'
|
||||
>
|
||||
|
||||
/** Picked rather than spread: the session's `accept` and `close` are the client's alone. */
|
||||
export function shellSessionListeners(
|
||||
session: BridgeClientShellSession
|
||||
): BridgeShellSessionListeners {
|
||||
return {
|
||||
onReady: session.onReady,
|
||||
onRouteUpdate: session.onRouteUpdate,
|
||||
onStorageUpdate: session.onStorageUpdate,
|
||||
onSafeAreaInsetsUpdate: session.onSafeAreaInsetsUpdate,
|
||||
onKeyboardInsetUpdate: session.onKeyboardInsetUpdate,
|
||||
onColorSchemeUpdate: session.onColorSchemeUpdate,
|
||||
onUiLocaleUpdate: session.onUiLocaleUpdate,
|
||||
onPageGeometryUpdate: session.onPageGeometryUpdate,
|
||||
onFoldDivisionUpdate: session.onFoldDivisionUpdate,
|
||||
onTerminalKeyRailUpdate: session.onTerminalKeyRailUpdate
|
||||
}
|
||||
}
|
||||
|
||||
export function createBridgeClientShellSession(args: {
|
||||
onReplaced: () => void
|
||||
prime: (connection: BridgeConnectionSnapshot) => void
|
||||
}): BridgeClientShellSession {
|
||||
let session: BridgeShellSession | null = null
|
||||
const storageListeners = new Set<() => void>()
|
||||
const readyListeners = new Set<() => void>()
|
||||
const routeUpdateListeners = new Set<(route: BridgeInitRoute | null) => void>()
|
||||
const insetsListeners = new Set<(insets: BridgeSafeAreaInsets) => void>()
|
||||
const keyboardListeners = new Set<(height: number) => void>()
|
||||
const geometryListeners = new Set<(geometry: BridgePageGeometry) => void>()
|
||||
const foldListeners = new Set<(division: BridgeFoldDivision | null) => void>()
|
||||
const railListeners = new Set<(keys: BridgeTerminalKeyRail) => void>()
|
||||
const schemeListeners = new Set<(scheme: BridgeColorScheme) => void>()
|
||||
const uiLocaleListeners = new Set<(locale: BridgeUiLocale | null) => void>()
|
||||
|
||||
@@ -48,34 +110,82 @@ export function createBridgeClientShellSession(args: {
|
||||
if (held !== null && update === null) {
|
||||
args.onReplaced()
|
||||
}
|
||||
// Preserve the original storage snapshot across same-session init while rebuilding the session value with new route/insets.
|
||||
const refreshShortcuts = update !== null && message.refreshTerminalShortcuts === true
|
||||
const sentStorage = message.storage ?? {}
|
||||
const sentOversize = message.storageOversize ?? []
|
||||
const storage = refreshShortcuts ? { ...update.storage } : undefined
|
||||
const storageOversize = refreshShortcuts
|
||||
? [
|
||||
...update.storageOversize.filter(
|
||||
(key) => !TERMINAL_SHORTCUT_STORAGE_KEYS.some((item) => item === key)
|
||||
),
|
||||
...TERMINAL_SHORTCUT_STORAGE_KEYS.filter((key) => sentOversize.includes(key))
|
||||
]
|
||||
: update?.storageOversize
|
||||
if (refreshShortcuts && storage) {
|
||||
for (const key of TERMINAL_SHORTCUT_STORAGE_KEYS) {
|
||||
if (sentStorage[key] === undefined) {
|
||||
delete storage[key]
|
||||
} else {
|
||||
storage[key] = sentStorage[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
const sent = message.safeAreaInsets ?? ZERO_SAFE_AREA_INSETS
|
||||
const movedInsets = held !== null && !sameSafeAreaInsets(held.safeAreaInsets, sent)
|
||||
// Reuse equal values’ identity so repeated ready cannot rerender keyed readers.
|
||||
const safeAreaInsets = held === null || movedInsets ? sent : held.safeAreaInsets
|
||||
const keyboardInset = message.keyboardInset ?? 0
|
||||
const movedKeyboard = held !== null && held.keyboardInset !== keyboardInset
|
||||
const sentGeometry = message.pageGeometry ?? EMPTY_PAGE_GEOMETRY
|
||||
const movedGeometry = held !== null && !samePageGeometry(held.pageGeometry, sentGeometry)
|
||||
const pageGeometry = held === null || movedGeometry ? sentGeometry : held.pageGeometry
|
||||
const sentFold = message.foldDivision ?? null
|
||||
const movedFold = held !== null && !sameFoldDivision(held.foldDivision, sentFold)
|
||||
const foldDivision = held === null || movedFold ? sentFold : held.foldDivision
|
||||
const sentRail = message.terminalKeyRail ?? []
|
||||
const movedRail = held !== null && !sameTerminalKeyRail(held.terminalKeyRail, sentRail)
|
||||
const terminalKeyRail = held === null || movedRail ? sentRail : held.terminalKeyRail
|
||||
const colorScheme = message.colorScheme ?? 'dark'
|
||||
const movedScheme = held !== null && held.colorScheme !== colorScheme
|
||||
const uiLocale = message.uiLocale ?? null
|
||||
const movedUiLocale = held !== null && held.uiLocale !== uiLocale
|
||||
session =
|
||||
update === null
|
||||
? { ...readShellSession(message), safeAreaInsets }
|
||||
? {
|
||||
...readShellSession(message),
|
||||
safeAreaInsets,
|
||||
pageGeometry,
|
||||
foldDivision,
|
||||
terminalKeyRail
|
||||
}
|
||||
: {
|
||||
...update,
|
||||
storage: storage ?? update.storage,
|
||||
storageOversize: storageOversize ?? update.storageOversize,
|
||||
route: message.route ?? null,
|
||||
safeAreaInsets,
|
||||
keyboardInset,
|
||||
pageGeometry,
|
||||
foldDivision,
|
||||
terminalKeyRail,
|
||||
colorScheme,
|
||||
uiLocale
|
||||
}
|
||||
// Reprime on every init to repair refused stale generations after client rebuild; identical pane-update snapshots emit no transition.
|
||||
// Re-primed either way, because a second `init` is also how the page recovers a cache it has
|
||||
// refused a `state` frame into: the shell rebuilt under it publishes a generation the page's
|
||||
// own is newer than, and this frame is what puts the two back in step. A pane update carries
|
||||
// the same snapshot it already holds, which `prime` answers with no transition.
|
||||
args.prime(message.connection)
|
||||
for (const listener of readyListeners) {
|
||||
listener()
|
||||
}
|
||||
readyListeners.clear()
|
||||
if (refreshShortcuts) {
|
||||
for (const listener of storageListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
if (movedInsets) {
|
||||
for (const listener of insetsListeners) {
|
||||
listener(safeAreaInsets)
|
||||
@@ -86,6 +196,21 @@ export function createBridgeClientShellSession(args: {
|
||||
listener(keyboardInset)
|
||||
}
|
||||
}
|
||||
if (movedGeometry) {
|
||||
for (const listener of geometryListeners) {
|
||||
listener(pageGeometry)
|
||||
}
|
||||
}
|
||||
if (movedFold) {
|
||||
for (const listener of foldListeners) {
|
||||
listener(foldDivision)
|
||||
}
|
||||
}
|
||||
if (movedRail) {
|
||||
for (const listener of railListeners) {
|
||||
listener(terminalKeyRail)
|
||||
}
|
||||
}
|
||||
if (movedScheme) {
|
||||
for (const listener of schemeListeners) {
|
||||
listener(colorScheme)
|
||||
@@ -96,7 +221,9 @@ export function createBridgeClientShellSession(args: {
|
||||
listener(uiLocale)
|
||||
}
|
||||
}
|
||||
// Publish only changed routes; repeated ready used for state repair must not retrigger the current pane.
|
||||
// Only a route that moved is an update. The shell answers every `ready` with the route it
|
||||
// holds, and the page re-asks after a refused `state` frame, so publishing each of those
|
||||
// would hand the pane hook the route it is already on.
|
||||
if (update === null || !bridgeRouteMoved(update.route, session.route)) {
|
||||
return
|
||||
}
|
||||
@@ -114,7 +241,14 @@ export function createBridgeClientShellSession(args: {
|
||||
readyListeners.delete(listener)
|
||||
}
|
||||
},
|
||||
// Late listeners read current; updates report changes without replaying the held route.
|
||||
// Not fired on subscribe, and no replay: a listener that arrives late reads the route it wants
|
||||
// off `current`, and what this publishes is the fact that one moved.
|
||||
onStorageUpdate: (listener) => {
|
||||
storageListeners.add(listener)
|
||||
return () => {
|
||||
storageListeners.delete(listener)
|
||||
}
|
||||
},
|
||||
onRouteUpdate: (listener) => {
|
||||
routeUpdateListeners.add(listener)
|
||||
return () => {
|
||||
@@ -133,6 +267,24 @@ export function createBridgeClientShellSession(args: {
|
||||
keyboardListeners.delete(listener)
|
||||
}
|
||||
},
|
||||
onPageGeometryUpdate: (listener) => {
|
||||
geometryListeners.add(listener)
|
||||
return () => {
|
||||
geometryListeners.delete(listener)
|
||||
}
|
||||
},
|
||||
onFoldDivisionUpdate: (listener) => {
|
||||
foldListeners.add(listener)
|
||||
return () => {
|
||||
foldListeners.delete(listener)
|
||||
}
|
||||
},
|
||||
onTerminalKeyRailUpdate: (listener) => {
|
||||
railListeners.add(listener)
|
||||
return () => {
|
||||
railListeners.delete(listener)
|
||||
}
|
||||
},
|
||||
onColorSchemeUpdate: (listener) => {
|
||||
schemeListeners.add(listener)
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { BridgeTerminalKeyId, BridgeTerminalToolbar } from './bridge-terminal-key'
|
||||
|
||||
export function createBridgeClientTerminalKeys(
|
||||
publishClaim: (claimed: boolean, inputOwner?: string, toolbar?: BridgeTerminalToolbar) => void
|
||||
) {
|
||||
const listeners = new Map<
|
||||
(key: BridgeTerminalKeyId, inputOwner?: string) => void,
|
||||
{ inputOwner?: string; toolbar?: BridgeTerminalToolbar }
|
||||
>()
|
||||
const publish = () => {
|
||||
const owner = [...listeners.values()].at(-1)
|
||||
publishClaim(listeners.size > 0, owner?.inputOwner, owner?.toolbar)
|
||||
}
|
||||
return {
|
||||
accept: (key: BridgeTerminalKeyId, inputOwner?: string) => {
|
||||
for (const [listener, owner] of listeners) {
|
||||
if (owner.inputOwner === inputOwner) {
|
||||
listener(key, inputOwner)
|
||||
}
|
||||
}
|
||||
},
|
||||
claim: (
|
||||
listener: (key: BridgeTerminalKeyId, inputOwner?: string) => void,
|
||||
inputOwner?: string,
|
||||
toolbar?: BridgeTerminalToolbar
|
||||
) => {
|
||||
const entry = (key: BridgeTerminalKeyId, owner?: string) => listener(key, owner)
|
||||
listeners.set(entry, { inputOwner, toolbar })
|
||||
publish()
|
||||
const release = () => {
|
||||
if (listeners.delete(entry)) {
|
||||
publish()
|
||||
}
|
||||
}
|
||||
return Object.assign(release, {
|
||||
update: (nextInputOwner?: string, nextToolbar?: BridgeTerminalToolbar) => {
|
||||
const owner = listeners.get(entry)
|
||||
if (owner && (owner.inputOwner !== nextInputOwner || owner.toolbar !== nextToolbar)) {
|
||||
owner.inputOwner = nextInputOwner
|
||||
owner.toolbar = nextToolbar
|
||||
publish()
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
reassert: () => {
|
||||
if (listeners.size > 0) {
|
||||
publish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,15 @@ import { BridgeInitRouteSchema, type BridgeInitRoute } from './bridge-init-route
|
||||
import { BridgePageRouteGrantsSchema } from './bridge-page-route-grants'
|
||||
import { BridgeSafeAreaInsetsSchema } from './bridge-safe-area-insets'
|
||||
import { BridgeKeyboardInsetSchema } from './bridge-keyboard-inset'
|
||||
import { BridgePageGeometrySchema } from './bridge-page-geometry'
|
||||
import { BridgeFoldDivisionSchema } from './bridge-fold-division'
|
||||
import { BridgeNotifySchema } from './bridge-notify-envelope'
|
||||
import { BRIDGE_BACK_FRAME } from './bridge-page-back'
|
||||
import {
|
||||
BRIDGE_TERMINAL_KEY_FRAME,
|
||||
BridgeTerminalKeyIdSchema,
|
||||
BridgeTerminalKeyRailSchema
|
||||
} from './bridge-terminal-key'
|
||||
import { BRIDGE_ID_PATTERN, idSchema, methodSchema, versionSchema } from './bridge-frame-fields'
|
||||
|
||||
export {
|
||||
@@ -148,7 +155,14 @@ const replyPartSchema = z.object({
|
||||
const BridgeClientMessageSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
v: versionSchema,
|
||||
type: z.literal('ready')
|
||||
type: z.literal('ready'),
|
||||
// Negotiated keys this page's `init` reader accepts. A malformed list reads as none, so the
|
||||
// page gets the legacy keys rather than no session.
|
||||
storageKeys: z
|
||||
.array(z.string().min(1).max(PAGE_STORAGE_MAX_KEY_CHARS))
|
||||
.max(PAGE_STORAGE_MAX_ENTRIES)
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
}),
|
||||
z.object({
|
||||
v: versionSchema,
|
||||
@@ -228,6 +242,13 @@ const BridgeHostMessageSchema = z.union([
|
||||
}),
|
||||
// Back names no destination; only the native stack knows what screen lies beneath.
|
||||
z.object({ v: versionSchema, type: z.literal(BRIDGE_BACK_FRAME) }),
|
||||
// One press on the shell's key rail. Sent only to a page granted the rail, which knows the frame.
|
||||
z.object({
|
||||
v: versionSchema,
|
||||
type: z.literal(BRIDGE_TERMINAL_KEY_FRAME),
|
||||
key: BridgeTerminalKeyIdSchema,
|
||||
inputOwner: z.string().min(1).max(128).optional()
|
||||
}),
|
||||
z.object({
|
||||
v: versionSchema,
|
||||
type: z.literal('end'),
|
||||
@@ -252,11 +273,30 @@ const BridgeHostMessageSchema = z.union([
|
||||
keyboardInset: BridgeKeyboardInsetSchema.optional(),
|
||||
// Older pages strip this key; salvage an unknown future scheme so it cannot invalidate the whole init.
|
||||
colorScheme: salvagedOptional('colorScheme', BridgeColorSchemeSchema).optional(),
|
||||
/** The fold splitting the page on a partially folded device; absent reads as none. Salvaged so a
|
||||
* value this page cannot read drops the field, not `init`. */
|
||||
pageGeometry: salvagedOptional('pageGeometry', BridgePageGeometrySchema).optional(),
|
||||
foldDivision: salvagedOptional('foldDivision', BridgeFoldDivisionSchema).optional(),
|
||||
/** The keys the shell's rail shows beside this page, which then drops them from its own row.
|
||||
* Absent reads as no rail. */
|
||||
terminalKeyRail: salvagedOptional('terminalKeyRail', BridgeTerminalKeyRailSchema).optional(),
|
||||
// Older pages strip this key; a locale this page does not ship is salvaged rather than refusing init.
|
||||
uiLocale: salvagedOptional('uiLocale', BridgeUiLocaleSchema).optional(),
|
||||
host: BridgeInitHostSchema.optional(),
|
||||
storage: BridgeInitStorageSchema.optional(),
|
||||
// Advisory page fast path for oversized keys; the shell independently refuses writes because older pages ignore this additive field (ruling 33.6).
|
||||
refreshTerminalShortcuts: salvagedOptional('refreshTerminalShortcuts', z.boolean()).optional(),
|
||||
/**
|
||||
* The allowlisted keys the shell holds a value for that `storage` could not carry, because the
|
||||
* app's value is over the page's own cap (ruling 33.6).
|
||||
*
|
||||
* Advisory, not the enforcement. The shell refuses a write to one of these on its own side
|
||||
* too, because a page served from an older desktop bundle ignores this field entirely and
|
||||
* would still replace what the device holds; this is the page's fast path, so a write it can
|
||||
* refuse locally rejects without a round trip and reaches its caller as `too-large`.
|
||||
*
|
||||
* Optional and additive: an older shell sends none and an older page ignores it. Bounded by
|
||||
* the same count as the storage record, since it names a subset of the same keys.
|
||||
*/
|
||||
storageOversize: z
|
||||
.array(z.string().min(1).max(PAGE_STORAGE_MAX_KEY_CHARS).refine(isPageStorageKey))
|
||||
.max(PAGE_STORAGE_MAX_ENTRIES)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createFakeBridgePortPair } from './bridge-port-pair-test-harness'
|
||||
import { readBridgeHostMessage } from './bridge-envelope'
|
||||
import { createBridgeInitFrame } from './bridge-init-frame'
|
||||
import { readShellSession } from './bridge-client-session'
|
||||
import type { BridgeFoldDivision } from './bridge-fold-division'
|
||||
|
||||
// iPhone Duo, inner display, half folded: the fold band with UIKit's 20pt margins either side.
|
||||
const BOOK: BridgeFoldDivision = { axis: 'vertical', start: 455.5, end: 495.5 }
|
||||
const TABLETOP: BridgeFoldDivision = { axis: 'horizontal', start: 455.5, end: 495.5 }
|
||||
|
||||
function initFrame(foldDivision?: BridgeFoldDivision | null) {
|
||||
return createBridgeInitFrame({
|
||||
sessionId: 'session-a',
|
||||
buildId: 'build-a',
|
||||
connection: {
|
||||
state: 'connected',
|
||||
reconnectAttempt: 0,
|
||||
lastConnectedAt: null,
|
||||
lastInboundAt: null,
|
||||
generation: null
|
||||
},
|
||||
route: { pathname: '/h/host-a' },
|
||||
pageRoutes: ['/h/[hostId]'],
|
||||
granted: [],
|
||||
host: { id: 'host-a', name: 'Host A', endpoint: 'ws://host-a', lastConnected: 0 },
|
||||
storage: {},
|
||||
...(foldDivision === undefined ? {} : { foldDivision })
|
||||
})
|
||||
}
|
||||
|
||||
function readInit(json: string) {
|
||||
const read = readBridgeHostMessage(json)
|
||||
if (!read.ok || read.message.type !== 'init') {
|
||||
throw new Error(
|
||||
`not an init the page would read: ${read.ok ? read.message.type : read.refusal}`
|
||||
)
|
||||
}
|
||||
return read.message
|
||||
}
|
||||
|
||||
describe('foldDivision on init', () => {
|
||||
it('omits no fold, and the page reads the absent field as none', () => {
|
||||
expect('foldDivision' in initFrame(null)).toBe(false)
|
||||
expect(readShellSession(readInit(JSON.stringify(initFrame()))).foldDivision).toBeNull()
|
||||
})
|
||||
|
||||
it('passes the fold through to the session the page holds', () => {
|
||||
expect(readShellSession(readInit(JSON.stringify(initFrame(BOOK)))).foldDivision).toEqual(BOOK)
|
||||
})
|
||||
|
||||
it('drops a fold the page cannot read without refusing the init around it', () => {
|
||||
for (const bad of [
|
||||
{ ...BOOK, end: 400 },
|
||||
{ ...BOOK, axis: 'diagonal' },
|
||||
{ ...BOOK, start: -1 }
|
||||
]) {
|
||||
const init = readInit(JSON.stringify({ ...initFrame(), foldDivision: bad }))
|
||||
expect(readShellSession(init).foldDivision, JSON.stringify(bad)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('a fold that moves under a live page', () => {
|
||||
it('re-sends init as the device folds, turns and opens, and the page reads each in place', async () => {
|
||||
const pair = createFakeBridgePortPair()
|
||||
await pair.flush()
|
||||
const sessionId = pair.client.getShellSession()?.sessionId
|
||||
const seen: (BridgeFoldDivision | null)[] = []
|
||||
pair.client.onFoldDivisionUpdate((division) => seen.push(division))
|
||||
|
||||
for (const division of [BOOK, TABLETOP, null]) {
|
||||
pair.host.publishFoldDivision(division)
|
||||
await pair.flush()
|
||||
expect(pair.client.getShellSession()?.foldDivision).toEqual(division)
|
||||
}
|
||||
expect(seen).toEqual([BOOK, TABLETOP, null])
|
||||
expect(pair.client.getShellSession()?.sessionId).toBe(sessionId)
|
||||
})
|
||||
|
||||
it('sends nothing for a fold that did not move', async () => {
|
||||
const pair = createFakeBridgePortPair({ foldDivision: BOOK })
|
||||
await pair.flush()
|
||||
const inits = () => pair.readToPage().filter((frame) => frame.type === 'init').length
|
||||
const before = inits()
|
||||
pair.host.publishFoldDivision({ ...BOOK })
|
||||
await pair.flush()
|
||||
expect(inits()).toBe(before)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod'
|
||||
import { BRIDGE_MAX_SAFE_AREA_INSET } from './bridge-safe-area-insets'
|
||||
|
||||
const offsetSchema = z.number().finite().min(0).max(BRIDGE_MAX_SAFE_AREA_INSET)
|
||||
|
||||
/**
|
||||
* The band a partially folded device's fold takes out of the page, in the page's own px: from
|
||||
* `start` to `end` across the width (`vertical`, book posture) or the height (`horizontal`,
|
||||
* tabletop posture). The page lays its panes out either side of it. Optional and additive: absent
|
||||
* reads as no fold, which is every device that does not fold and every shell older than the field.
|
||||
*/
|
||||
export const BridgeFoldDivisionSchema = z
|
||||
.object({
|
||||
axis: z.enum(['vertical', 'horizontal']),
|
||||
start: offsetSchema,
|
||||
end: offsetSchema
|
||||
})
|
||||
.refine((division) => division.end > division.start, 'Expected end after start')
|
||||
|
||||
export type BridgeFoldDivision = z.infer<typeof BridgeFoldDivisionSchema>
|
||||
|
||||
export function sameFoldDivision(
|
||||
left: BridgeFoldDivision | null,
|
||||
right: BridgeFoldDivision | null
|
||||
): boolean {
|
||||
return (
|
||||
left === right ||
|
||||
(left !== null &&
|
||||
right !== null &&
|
||||
left.axis === right.axis &&
|
||||
left.start === right.start &&
|
||||
left.end === right.end)
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
EMPTY_PAGE_GEOMETRY,
|
||||
samePageGeometry,
|
||||
type BridgePageGeometry
|
||||
} from './bridge-page-geometry'
|
||||
import { BRIDGE_MAX_PENDING_REQUESTS, BRIDGE_MAX_SUBSCRIPTIONS } from './bridge-caps'
|
||||
import { MOBILE_WEB_SHELL_GRANTS } from '../page-route-policy'
|
||||
import {
|
||||
@@ -16,6 +21,8 @@ import {
|
||||
ZERO_SAFE_AREA_INSETS,
|
||||
type BridgeSafeAreaInsets
|
||||
} from './bridge-safe-area-insets'
|
||||
import type { BridgeFoldDivision } from './bridge-fold-division'
|
||||
import type { BridgeTerminalKeyRail } from './bridge-terminal-key'
|
||||
|
||||
// Grant the intersection of implemented capabilities and route declarations, plus protocol fault. Use the same resolved list for init and enforcement; new names need no version bump.
|
||||
export const BRIDGE_NATIVE_GRANTS: readonly string[] = [
|
||||
@@ -32,6 +39,11 @@ export function createBridgeInitFrame(args: {
|
||||
pageRoutes: readonly string[]
|
||||
safeAreaInsets?: BridgeSafeAreaInsets
|
||||
keyboardInset?: number
|
||||
/** The fold splitting the page, in its px; null or absent when none does. */
|
||||
pageGeometry?: BridgePageGeometry
|
||||
foldDivision?: BridgeFoldDivision | null
|
||||
/** The keys the shell's rail shows beside the page; omitted while none does. */
|
||||
terminalKeyRail?: BridgeTerminalKeyRail
|
||||
colorScheme?: BridgeColorScheme
|
||||
uiLocale?: BridgeUiLocale
|
||||
// Omit absent manifest pairs so older routing behavior remains available.
|
||||
@@ -39,7 +51,10 @@ export function createBridgeInitFrame(args: {
|
||||
granted: readonly string[]
|
||||
host: BridgeInitHost
|
||||
storage: Readonly<Record<string, string>>
|
||||
// Withheld oversized storage keys reject page writes; absent/empty both mean no app value was excluded (ruling 33.6).
|
||||
refreshTerminalShortcuts?: boolean
|
||||
/** The allowlisted keys whose app-side value is over the page's cap, so `storage` has none
|
||||
* (ruling 33.6). The page refuses its own writes to these rather than replacing the device's.
|
||||
* Absent and empty are the same answer: nothing of the app's was left out. */
|
||||
storageOversize?: readonly string[]
|
||||
ownsHostArea?: boolean
|
||||
}): Extract<BridgeHostMessage, { type: 'init' }> {
|
||||
@@ -65,6 +80,21 @@ export function createBridgeInitFrame(args: {
|
||||
...(args.keyboardInset === undefined || args.keyboardInset === 0
|
||||
? {}
|
||||
: { keyboardInset: args.keyboardInset }),
|
||||
...(args.pageGeometry === undefined || samePageGeometry(args.pageGeometry, EMPTY_PAGE_GEOMETRY)
|
||||
? {}
|
||||
: {
|
||||
pageGeometry: {
|
||||
...args.pageGeometry,
|
||||
occlusions: args.pageGeometry.occlusions.map((rect) => ({ ...rect }))
|
||||
}
|
||||
}),
|
||||
...(args.foldDivision === undefined || args.foldDivision === null
|
||||
? {}
|
||||
: { foldDivision: { ...args.foldDivision } }),
|
||||
...(args.refreshTerminalShortcuts ? { refreshTerminalShortcuts: true } : {}),
|
||||
...(args.terminalKeyRail === undefined || args.terminalKeyRail.length === 0
|
||||
? {}
|
||||
: { terminalKeyRail: [...args.terminalKeyRail] }),
|
||||
// Omit dark: it is what pages assume when an older shell sends nothing.
|
||||
...(args.colorScheme === 'light' ? { colorScheme: 'light' as const } : {}),
|
||||
// Sent even for English: an absent field lets the page fall back to the WebView's language.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { RpcResponse, RpcSuccess } from '../../transport/types'
|
||||
import { BridgeClientNotNativeVerbError } from './bridge-client-errors'
|
||||
import { isBridgeNativeMethod, type BridgeNativeVerb } from './bridge-native-verbs'
|
||||
|
||||
export function callBridgeNativeVerb(
|
||||
sendRequest: (method: string, params: unknown) => Promise<RpcResponse>,
|
||||
verb: BridgeNativeVerb,
|
||||
params: unknown
|
||||
): Promise<RpcSuccess> {
|
||||
// Validate the typed verb at runtime too; widened callers must not turn this into an untracked raw desktop RPC port.
|
||||
if (!isBridgeNativeMethod(verb)) {
|
||||
return Promise.reject(new BridgeClientNotNativeVerbError(verb))
|
||||
}
|
||||
return sendRequest(verb, params).then((reply) => {
|
||||
// Native refusals reject as error frames and never forward to desktop, so the return has no RpcFailure arm.
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error.message)
|
||||
}
|
||||
return reply
|
||||
})
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { BRIDGE_LIVENESS_PROBE_NOTIFY_FIELDS } from './bridge-liveness-probe-not
|
||||
import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back'
|
||||
import { BRIDGE_PAGE_LOCATION } from './bridge-page-location'
|
||||
import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted'
|
||||
import {
|
||||
BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
|
||||
BridgeTerminalToolbarSchema
|
||||
} from './bridge-terminal-key'
|
||||
import { BRIDGE_CLEARABLE_ROUTE_PARAMS, BRIDGE_ROUTE_PARAM_CLEAR } from './bridge-route-update'
|
||||
import {
|
||||
isPageStorageKey,
|
||||
@@ -102,12 +106,22 @@ export const BridgeNotifySchema = z.discriminatedUnion('name', [
|
||||
name: z.literal(BRIDGE_BACK_CLAIM_NOTIFY),
|
||||
claimed: z.boolean()
|
||||
}),
|
||||
// A host-area page navigates in its own document; report where so a fold can reopen it natively.
|
||||
// Pages from desktops released with #34484 navigate inside their own document and report where, so a fold can reopen it natively.
|
||||
z.object({
|
||||
v: versionSchema,
|
||||
type: z.literal('notify'),
|
||||
name: z.literal(BRIDGE_PAGE_LOCATION),
|
||||
href: z.string().min(1).max(BRIDGE_MAX_ROUTE_HREF_CHARS).regex(BRIDGE_ROUTE_HREF_PATTERN)
|
||||
}),
|
||||
// Behind the `terminalKeyRail` grant, which is what keeps it off a shell that has no rail. Whole
|
||||
// state rather than a toggle, for the reason the Back claim is.
|
||||
z.object({
|
||||
v: versionSchema,
|
||||
type: z.literal('notify'),
|
||||
name: z.literal(BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY),
|
||||
claimed: z.boolean(),
|
||||
inputOwner: z.string().min(1).max(128).optional(),
|
||||
toolbar: BridgeTerminalToolbarSchema.optional()
|
||||
})
|
||||
])
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import { BRIDGE_BACK_CLAIM_NOTIFY } from './bridge-page-back'
|
||||
import { BRIDGE_PAGE_LOCATION } from './bridge-page-location'
|
||||
import { BRIDGE_PAGE_PAINTED } from './bridge-page-painted'
|
||||
import { BRIDGE_ROUTE_PARAM_CLEAR } from './bridge-route-update'
|
||||
import {
|
||||
BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
|
||||
BRIDGE_TERMINAL_KEY_RAIL_GRANT
|
||||
} from './bridge-terminal-key'
|
||||
|
||||
export type BridgeNotifyName = Extract<BridgeClientMessage, { type: 'notify' }>['name']
|
||||
|
||||
@@ -28,6 +32,7 @@ const BRIDGE_NOTIFY_GRANTS: Readonly<Record<BridgeNotifyName, string | null>> =
|
||||
[BRIDGE_EXTERNAL_LINK_GRANT]: BRIDGE_EXTERNAL_LINK_GRANT,
|
||||
[BRIDGE_FAULT_GRANT]: BRIDGE_FAULT_GRANT,
|
||||
[BRIDGE_HAPTICS_NOTIFY]: BRIDGE_HAPTICS_GRANT,
|
||||
[BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY]: BRIDGE_TERMINAL_KEY_RAIL_GRANT,
|
||||
[BRIDGE_LIVENESS_PROBE_NOTIFY]: null
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createFakeBridgePortPair } from './bridge-port-pair-test-harness'
|
||||
import {
|
||||
EMPTY_PAGE_GEOMETRY,
|
||||
pageOcclusions,
|
||||
type BridgePageGeometry
|
||||
} from './bridge-page-geometry'
|
||||
|
||||
const GEOMETRY: BridgePageGeometry = {
|
||||
sideRail: true,
|
||||
occlusions: [{ x: 0, y: 0, width: 124, height: 84 }]
|
||||
}
|
||||
|
||||
describe('page geometry over the bridge', () => {
|
||||
it('translates and clips native occlusions into the WebView frame', () => {
|
||||
expect(
|
||||
pageOcclusions(
|
||||
[
|
||||
{ x: 20, y: 30, width: 100, height: 90 },
|
||||
{ x: 0, y: 0, width: 20, height: 20 },
|
||||
{ x: 300, y: 300, width: 100, height: 100 }
|
||||
],
|
||||
{ x: 40, y: 50, width: 320, height: 320 }
|
||||
)
|
||||
).toEqual([
|
||||
{ x: 0, y: 0, width: 80, height: 70 },
|
||||
{ x: 260, y: 250, width: 60, height: 70 }
|
||||
])
|
||||
expect(pageOcclusions(GEOMETRY.occlusions, null)).toEqual([])
|
||||
})
|
||||
|
||||
it('updates geometry in place, deduplicates it and clears it on rotation', async () => {
|
||||
const pair = createFakeBridgePortPair()
|
||||
await pair.flush()
|
||||
expect(pair.client.getShellSession()?.pageGeometry).toEqual(EMPTY_PAGE_GEOMETRY)
|
||||
const seen: BridgePageGeometry[] = []
|
||||
pair.client.onPageGeometryUpdate((geometry) => seen.push(geometry))
|
||||
pair.host.publishPageGeometry(GEOMETRY)
|
||||
await pair.flush()
|
||||
expect(pair.client.getShellSession()?.pageGeometry).toEqual(GEOMETRY)
|
||||
pair.host.publishPageGeometry({ ...GEOMETRY, occlusions: [...GEOMETRY.occlusions] })
|
||||
await pair.flush()
|
||||
expect(seen).toEqual([GEOMETRY])
|
||||
pair.host.publishPageGeometry(EMPTY_PAGE_GEOMETRY)
|
||||
await pair.flush()
|
||||
expect(seen).toEqual([GEOMETRY, EMPTY_PAGE_GEOMETRY])
|
||||
})
|
||||
|
||||
it('salvages an init with invalid geometry without trusting its rectangles', async () => {
|
||||
for (const bad of [
|
||||
{ ...GEOMETRY, occlusions: [{ x: -1, y: 0, width: 12, height: 84 }] },
|
||||
{ ...GEOMETRY, occlusions: Array(17).fill(GEOMETRY.occlusions[0]) },
|
||||
{ ...GEOMETRY, sideRail: 'yes' }
|
||||
]) {
|
||||
const pair = createFakeBridgePortPair({
|
||||
rewriteToPage: (json) => {
|
||||
const frame = JSON.parse(json)
|
||||
return JSON.stringify(frame.type === 'init' ? { ...frame, pageGeometry: bad } : frame)
|
||||
}
|
||||
})
|
||||
await pair.flush()
|
||||
expect(pair.client.getShellSession()?.pageGeometry).toEqual(EMPTY_PAGE_GEOMETRY)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('delivers D-pad space changes even when the side rail and occlusions are unchanged', async () => {
|
||||
const pair = createFakeBridgePortPair()
|
||||
await pair.flush()
|
||||
const seen: BridgePageGeometry[] = []
|
||||
pair.client.onPageGeometryUpdate((geometry) => seen.push(geometry))
|
||||
pair.host.publishPageGeometry({ ...GEOMETRY, directionPadAvailable: true })
|
||||
await pair.flush()
|
||||
pair.host.publishPageGeometry({ ...GEOMETRY, directionPadAvailable: false })
|
||||
await pair.flush()
|
||||
expect(seen).toEqual([
|
||||
{ ...GEOMETRY, directionPadAvailable: true },
|
||||
{ ...GEOMETRY, directionPadAvailable: false }
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod'
|
||||
import { BRIDGE_MAX_SAFE_AREA_INSET } from './bridge-safe-area-insets'
|
||||
|
||||
const coordinate = z.number().finite().min(0).max(BRIDGE_MAX_SAFE_AREA_INSET)
|
||||
export const BridgePageGeometrySchema = z.object({
|
||||
occlusions: z
|
||||
.array(z.object({ x: coordinate, y: coordinate, width: coordinate, height: coordinate }))
|
||||
.max(16),
|
||||
sideRail: z.boolean(),
|
||||
directionPadAvailable: z.boolean().optional()
|
||||
})
|
||||
export type BridgePageGeometry = z.infer<typeof BridgePageGeometrySchema>
|
||||
export const EMPTY_PAGE_GEOMETRY: BridgePageGeometry = { occlusions: [], sideRail: false }
|
||||
export function samePageGeometry(a: BridgePageGeometry, b: BridgePageGeometry): boolean {
|
||||
return (
|
||||
a.sideRail === b.sideRail &&
|
||||
a.directionPadAvailable === b.directionPadAvailable &&
|
||||
a.occlusions.length === b.occlusions.length &&
|
||||
a.occlusions.every((rect, i) => {
|
||||
const other = b.occlusions[i]
|
||||
return (
|
||||
other !== undefined &&
|
||||
rect.x === other.x &&
|
||||
rect.y === other.y &&
|
||||
rect.width === other.width &&
|
||||
rect.height === other.height
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Native regions are window points; the web header measures in the WebView's local points. */
|
||||
export function pageOcclusions(
|
||||
regions: readonly { x: number; y: number; width: number; height: number }[],
|
||||
frame: { x: number; y: number; width: number; height: number } | null
|
||||
): BridgePageGeometry['occlusions'] {
|
||||
if (frame === null) {
|
||||
return []
|
||||
}
|
||||
return regions.flatMap((rect) => {
|
||||
const x = Math.max(0, rect.x - frame.x)
|
||||
const y = Math.max(0, rect.y - frame.y)
|
||||
const right = Math.min(frame.width, rect.x + rect.width - frame.x)
|
||||
const bottom = Math.min(frame.height, rect.y + rect.height - frame.y)
|
||||
return right > x && bottom > y ? [{ x, y, width: right - x, height: bottom - y }] : []
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS,
|
||||
MOBILE_WEB_BUNDLE_MAX_GRANTED_ROUTE_GRANTS,
|
||||
MobileWebBundleGrantNameSchema
|
||||
} from '../../../../src/shared/mobile-web-bundle/manifest-contract'
|
||||
import { BRIDGE_MAX_PAGE_ROUTES, BRIDGE_MAX_ROUTE_PATHNAME_CHARS } from './bridge-caps'
|
||||
@@ -11,7 +11,9 @@ export const BridgePageRouteGrantsSchema = z
|
||||
z
|
||||
.object({
|
||||
pathname: z.string().min(1).max(BRIDGE_MAX_ROUTE_PATHNAME_CHARS),
|
||||
grants: z.array(MobileWebBundleGrantNameSchema).max(MOBILE_WEB_BUNDLE_MAX_ROUTE_GRANTS)
|
||||
grants: z
|
||||
.array(MobileWebBundleGrantNameSchema)
|
||||
.max(MOBILE_WEB_BUNDLE_MAX_GRANTED_ROUTE_GRANTS)
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from './bridge-envelope'
|
||||
import type { BridgeErrorCapture } from './bridge-error-capture'
|
||||
import type { BridgeSafeAreaInsets } from './bridge-safe-area-insets'
|
||||
import type { BridgeFoldDivision } from './bridge-fold-division'
|
||||
import type { BridgeTerminalKeyRail } from './bridge-terminal-key'
|
||||
import {
|
||||
createBridgeRpcClient,
|
||||
type BridgeRpcClient,
|
||||
@@ -39,6 +41,10 @@ export type BridgePortPair<TRpc extends RpcClient = FakeRpcClient> = {
|
||||
readonly pageReadyCount: () => number
|
||||
readonly pagePaintCount: () => number
|
||||
readonly backClaims: boolean[]
|
||||
/** Every claim on the key rail the host reported, drops on `ready` and `close` included. */
|
||||
readonly terminalKeyRailClaims: boolean[]
|
||||
/** What each answered `ready` declared it reports, in order. */
|
||||
/** Every clear the page asked the shell for, in order. */
|
||||
readonly routeParamClears: () => readonly { param: string; value: string }[]
|
||||
readonly routeRefusals: string[]
|
||||
flush: () => Promise<void>
|
||||
@@ -64,6 +70,9 @@ export type BridgePortPairOptions<TRpc extends RpcClient> = {
|
||||
serveNativeVerb?: (verb: BridgeNativeVerb, params: unknown) => Promise<unknown>
|
||||
safeAreaInsets?: BridgeSafeAreaInsets
|
||||
keyboardInset?: number
|
||||
foldDivision?: BridgeFoldDivision | null
|
||||
terminalKeyRail?: BridgeTerminalKeyRail
|
||||
/** Stands for the wide host-area session, the one shell mount that tells the page it owns it. */
|
||||
ownsHostArea?: boolean
|
||||
onStorageWrite?: (key: string, value: string | null) => void
|
||||
}
|
||||
@@ -168,6 +177,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
|
||||
const storageWrites: { key: string; value: string | null }[] = []
|
||||
const pageFaults: BridgeErrorCapture[] = []
|
||||
const backClaims: boolean[] = []
|
||||
const terminalKeyRailClaims: boolean[] = []
|
||||
let pageReadies = 0
|
||||
let pagePaints = 0
|
||||
const routeParamClears: { param: string; value: string }[] = []
|
||||
@@ -189,6 +199,8 @@ export function createBridgePortPair<TRpc extends RpcClient>(
|
||||
route: options.route ?? { pathname: '/h/host-a' },
|
||||
...(options.safeAreaInsets === undefined ? {} : { safeAreaInsets: options.safeAreaInsets }),
|
||||
...(options.keyboardInset === undefined ? {} : { keyboardInset: options.keyboardInset }),
|
||||
...(options.foldDivision === undefined ? {} : { foldDivision: options.foldDivision }),
|
||||
...(options.terminalKeyRail === undefined ? {} : { terminalKeyRail: options.terminalKeyRail }),
|
||||
readClientIdentity: () =>
|
||||
options.clientIdentity === undefined ? PORT_PAIR_CLIENT_IDENTITY : options.clientIdentity,
|
||||
pageRoutes: options.pageRoutes ?? ['/h/[hostId]'],
|
||||
@@ -223,6 +235,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
|
||||
},
|
||||
onPageBackClaim: (claimed) => backClaims.push(claimed),
|
||||
onPageLocation: () => {},
|
||||
onTerminalKeyRailClaim: (claimed) => terminalKeyRailClaims.push(claimed),
|
||||
onRouteParamClear: (param, value) => routeParamClears.push({ param, value }),
|
||||
onRouteRefused: (issue) => routeRefusals.push(issue),
|
||||
onDiagnostic: (diagnostic) => hostDiagnostics.push(diagnostic)
|
||||
@@ -260,6 +273,7 @@ export function createBridgePortPair<TRpc extends RpcClient>(
|
||||
pageReadyCount: () => pageReadies,
|
||||
pagePaintCount: () => pagePaints,
|
||||
backClaims,
|
||||
terminalKeyRailClaims,
|
||||
routeParamClears: () => routeParamClears,
|
||||
routeRefusals,
|
||||
async flush(): Promise<void> {
|
||||
|
||||
@@ -36,9 +36,11 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('bridge client handshake', () => {
|
||||
it('asks for a session as soon as it exists, declaring nothing', () => {
|
||||
it('asks for a session immediately and declares the storage keys it can read', () => {
|
||||
const page = createPageClient()
|
||||
expect(page.frames()).toEqual([{ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }])
|
||||
expect(page.frames()).toEqual([
|
||||
{ v: BRIDGE_PROTOCOL_VERSION, type: 'ready', storageKeys: ['orca:terminalSplitLayout'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps asking on a widening backoff until init answers', () => {
|
||||
@@ -123,7 +125,11 @@ describe('bridge client handshake', () => {
|
||||
safeAreaInsets: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
keyboardInset: 0,
|
||||
colorScheme: 'dark',
|
||||
foldDivision: null,
|
||||
pageGeometry: { occlusions: [], sideRail: false },
|
||||
terminalKeyRail: [],
|
||||
uiLocale: null,
|
||||
// And no host and no stored keys, which is what `host-store.web.ts` then answers with.
|
||||
host: null,
|
||||
storage: {},
|
||||
storageOversize: [],
|
||||
|
||||
@@ -8,12 +8,17 @@ import {
|
||||
} from './bridge-caps'
|
||||
import { BridgeConnectionCache } from './bridge-client-connection-cache'
|
||||
import type { BridgeRpcClientDiagnostic } from './bridge-client-diagnostics'
|
||||
import { createBridgeClientShellSession } from './bridge-client-shell-session'
|
||||
import {
|
||||
createBridgeClientShellSession,
|
||||
shellSessionListeners,
|
||||
type BridgeShellSessionListeners
|
||||
} from './bridge-client-shell-session'
|
||||
import type { BridgeShellSession } from './bridge-client-session'
|
||||
import { createBridgeInitHandshake, createPageReadyFrame } from './bridge-client-init-handshake'
|
||||
import {
|
||||
BridgeClientCapExceededError,
|
||||
BridgeClientClosedError,
|
||||
BridgeClientNotNativeVerbError,
|
||||
BridgeClientNotReadyError,
|
||||
BridgeRequestOversizedError,
|
||||
BridgeSendFailedError,
|
||||
@@ -22,20 +27,18 @@ import {
|
||||
import type { BridgeHapticsKind } from './bridge-haptics-notify'
|
||||
import { createBridgeInboundFrameReader } from './bridge-client-inbound-frames'
|
||||
import { createBridgeClientNotifications } from './bridge-client-notifications'
|
||||
import type { BridgeSafeAreaInsets } from './bridge-safe-area-insets'
|
||||
import { createBridgeClientTerminalKeys } from './bridge-client-terminal-keys'
|
||||
import { createPageBackConsumers, type PageBackConsumer } from './page-back-consumers'
|
||||
import { BridgeClientRequests } from './bridge-client-requests'
|
||||
import { readBridgeRequestFrameOptions, readBridgeRpcEnvelope } from './bridge-client-rpc-envelope'
|
||||
import { BridgeClientSubscriptions } from './bridge-client-subscriptions'
|
||||
import type { BridgeNativeVerb } from './bridge-native-verbs'
|
||||
import { callBridgeNativeVerb } from './bridge-native-verb-call'
|
||||
import { isBridgeNativeMethod, type BridgeNativeVerb } from './bridge-native-verbs'
|
||||
import { BRIDGE_ROUTE_PARAM_CLEAR, type BridgeClearableRouteParam } from './bridge-route-update'
|
||||
import {
|
||||
BRIDGE_PROTOCOL_VERSION,
|
||||
BRIDGE_RPC_ENVELOPE_GRANT,
|
||||
type BridgeClientMessage,
|
||||
type BridgeConnectionSnapshot,
|
||||
type BridgeInitRoute
|
||||
type BridgeConnectionSnapshot
|
||||
} from './bridge-envelope'
|
||||
|
||||
export type { BridgeShellSession } from './bridge-client-session'
|
||||
@@ -62,36 +65,33 @@ export type BridgeRpcClientOptions = {
|
||||
onDiagnostic?: (diagnostic: BridgeRpcClientDiagnostic) => void
|
||||
}
|
||||
|
||||
export type BridgeRpcClient = RpcClient & {
|
||||
onReady: (listener: () => void) => () => void
|
||||
// Publish changed params only, never initial/repeated init. Clear consumed one-shot params so repeat taps to the same pane still appear as real changes.
|
||||
onRouteUpdate: (listener: (route: BridgeInitRoute | null) => void) => () => void
|
||||
onSafeAreaInsetsUpdate: (listener: (insets: BridgeSafeAreaInsets) => void) => () => void
|
||||
onKeyboardInsetUpdate: (listener: (height: number) => void) => () => void
|
||||
onColorSchemeUpdate: (listener: (scheme: BridgeShellSession['colorScheme']) => void) => () => void
|
||||
onUiLocaleUpdate: (listener: (locale: BridgeShellSession['uiLocale']) => void) => () => void
|
||||
getShellSession: () => BridgeShellSession | null
|
||||
// False without navigate lets tap handlers fall back safely instead of throwing into uncaught UI callbacks.
|
||||
notifyNavigate: (href: string, replace?: boolean) => boolean
|
||||
// The page owns no native history; ask the shell to pop. Missing grant returns false; older shells may also drop the new notify, so callers retain their router fallback.
|
||||
notifyNavigateBack: () => boolean
|
||||
// False means missing externalLink grant or refused URL; callers must offer another outcome for the tap.
|
||||
notifyExternalLink: (url: string) => boolean
|
||||
// Use the existing request/id/cap lane but keep native methods inside the bridge’s typed surface; they are device verbs, not catalogued desktop RPCs.
|
||||
callNativeVerb: (verb: BridgeNativeVerb, params: unknown) => Promise<RpcSuccess>
|
||||
// Only the page can report a painted tree; WebView commit and ready happen before that proof exists.
|
||||
notifyPagePainted: () => void
|
||||
// Older shells refuse the frame with a diagnostic only; they reopen the host route after a fold.
|
||||
notifyPageLocation: (href: string) => void
|
||||
// Claim Back until disposal; invoke consumers newest-first with false fallthrough. Return unhandled presses to the shell so a race with sheet closure cannot swallow navigation.
|
||||
claimBack: (consumer: PageBackConsumer) => () => void
|
||||
notifyStorageWrite: (key: string, value: string | null) => boolean
|
||||
notifyHaptics: (kind: BridgeHapticsKind) => boolean
|
||||
// Never throw from error-boundary reporting. False means no frame left; shell load recovery covers reports that cannot be sent, without retrying this fault.
|
||||
notifyPageFault: (error: unknown) => boolean
|
||||
// After applying a one-shot param, ask its holder to clear that value (ruling 34). A failed clear is repaired when init delivers the still-held request again.
|
||||
clearRouteParam: (param: BridgeClearableRouteParam, value: string) => boolean
|
||||
}
|
||||
export type BridgeRpcClient = RpcClient &
|
||||
BridgeShellSessionListeners & {
|
||||
/**
|
||||
* Asks the shell to draw its key rail, while a terminal and its key row are on screen, until the
|
||||
* dispose is called; `listener` takes each press. Nothing is sent to a shell without the grant.
|
||||
*/
|
||||
claimTerminalKeyRail: ReturnType<typeof createBridgeClientTerminalKeys>['claim']
|
||||
getShellSession: () => BridgeShellSession | null
|
||||
// False without navigate lets tap handlers fall back safely instead of throwing into uncaught UI callbacks.
|
||||
notifyNavigate: (href: string, replace?: boolean) => boolean
|
||||
// The page owns no native history; ask the shell to pop. Missing grant returns false; older shells may also drop the new notify, so callers retain their router fallback.
|
||||
notifyNavigateBack: () => boolean
|
||||
// False means missing externalLink grant or refused URL; callers must offer another outcome for the tap.
|
||||
notifyExternalLink: (url: string) => boolean
|
||||
// Use the existing request/id/cap lane but keep native methods inside the bridge’s typed surface; they are device verbs, not catalogued desktop RPCs.
|
||||
callNativeVerb: (verb: BridgeNativeVerb, params: unknown) => Promise<RpcSuccess>
|
||||
// Only the page can report a painted tree; WebView commit and ready happen before that proof exists.
|
||||
notifyPagePainted: () => void
|
||||
// Claim Back until disposal; invoke consumers newest-first with false fallthrough. Return unhandled presses to the shell so a race with sheet closure cannot swallow navigation.
|
||||
claimBack: (consumer: PageBackConsumer) => () => void
|
||||
notifyStorageWrite: (key: string, value: string | null) => boolean
|
||||
notifyHaptics: (kind: BridgeHapticsKind) => boolean
|
||||
// Never throw from error-boundary reporting. False means no frame left; shell load recovery covers reports that cannot be sent, without retrying this fault.
|
||||
notifyPageFault: (error: unknown) => boolean
|
||||
// After applying a one-shot param, ask its holder to clear that value (ruling 34). A failed clear is repaired when init delivers the still-held request again.
|
||||
clearRouteParam: (param: BridgeClearableRouteParam, value: string) => boolean
|
||||
}
|
||||
|
||||
// Expose synchronous RpcClient getters from init-primed state so first renders match native clients. Page close settles its own work, never the native client shared by other screens.
|
||||
// Ungated calls before init throw for mount-order bugs; after close, teardown calls are inert and getters retain a disconnected snapshot. Stream failures notify listeners.
|
||||
@@ -135,9 +135,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
|
||||
|
||||
const subscriptions = new BridgeClientSubscriptions({
|
||||
send: (frame) => posted(frame),
|
||||
onDroppedBinaryFrame: () => {
|
||||
report({ kind: 'binary-frame-dropped' })
|
||||
}
|
||||
onDroppedBinaryFrame: () => report({ kind: 'binary-frame-dropped' })
|
||||
})
|
||||
|
||||
const handshake = createBridgeInitHandshake(() => {
|
||||
@@ -196,9 +194,11 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
|
||||
handshake.stop()
|
||||
shellSession.accept(message)
|
||||
back.reassert()
|
||||
terminalKeys.reassert()
|
||||
},
|
||||
acceptState,
|
||||
acceptBack: back.press
|
||||
acceptBack: back.press,
|
||||
acceptTerminalKey: (key, inputOwner) => terminalKeys.accept(key, inputOwner)
|
||||
})
|
||||
|
||||
function receive(json: string): void {
|
||||
@@ -301,6 +301,7 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
|
||||
isClosed: () => closed,
|
||||
hasGrant
|
||||
})
|
||||
const terminalKeys = createBridgeClientTerminalKeys(notifications.notifyTerminalKeyRailClaim)
|
||||
|
||||
const unsubscribeFromMessages = options.onMessage(receive)
|
||||
handshake.start()
|
||||
@@ -323,20 +324,27 @@ export function createBridgeRpcClient(options: BridgeRpcClientOptions): BridgeRp
|
||||
notifyNavigate: notifications.notifyNavigate,
|
||||
notifyNavigateBack: notifications.notifyNavigateBack,
|
||||
notifyExternalLink: notifications.notifyExternalLink,
|
||||
callNativeVerb: (verb, params) => callBridgeNativeVerb(sendRequest, verb, params),
|
||||
callNativeVerb: (verb, params) => {
|
||||
// Validate the typed verb at runtime too; widened callers must not turn this into an untracked raw desktop RPC port.
|
||||
if (!isBridgeNativeMethod(verb)) {
|
||||
return Promise.reject(new BridgeClientNotNativeVerbError(verb))
|
||||
}
|
||||
return sendRequest(verb, params).then((reply) => {
|
||||
// Native refusals reject as error frames and never forward to desktop, so the return has no RpcFailure arm.
|
||||
if (!reply.ok) {
|
||||
throw new Error(reply.error.message)
|
||||
}
|
||||
return reply
|
||||
})
|
||||
},
|
||||
notifyStorageWrite: notifications.notifyStorageWrite,
|
||||
notifyHaptics: notifications.notifyHaptics,
|
||||
notifyPageFault: notifications.notifyPageFault,
|
||||
notifyPagePainted: notifications.notifyPagePainted,
|
||||
notifyPageLocation: notifications.notifyPageLocation,
|
||||
claimBack: back.claim,
|
||||
close,
|
||||
onReady: shellSession.onReady,
|
||||
onRouteUpdate: shellSession.onRouteUpdate,
|
||||
onSafeAreaInsetsUpdate: shellSession.onSafeAreaInsetsUpdate,
|
||||
onKeyboardInsetUpdate: shellSession.onKeyboardInsetUpdate,
|
||||
onColorSchemeUpdate: shellSession.onColorSchemeUpdate,
|
||||
onUiLocaleUpdate: shellSession.onUiLocaleUpdate,
|
||||
...shellSessionListeners(shellSession),
|
||||
claimTerminalKeyRail: terminalKeys.claim,
|
||||
getShellSession: shellSession.current,
|
||||
clearRouteParam: (param, value) =>
|
||||
posted({
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { MOBILE_WEB_SHELL_GRANTS } from '../page-route-policy'
|
||||
import { createFakeBridgePortPair } from './bridge-port-pair-test-harness'
|
||||
import { BRIDGE_PROTOCOL_VERSION } from './bridge-envelope'
|
||||
import {
|
||||
BridgeTerminalKeyIdSchema,
|
||||
BridgeTerminalToolbarSchema,
|
||||
BRIDGE_TERMINAL_KEY_IDS,
|
||||
BRIDGE_TERMINAL_MODIFIER_IDS,
|
||||
BRIDGE_TERMINAL_TOOLBAR_KEY_IDS,
|
||||
BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY,
|
||||
BRIDGE_TERMINAL_KEY_RAIL_GRANT,
|
||||
type BridgeTerminalKeyId,
|
||||
type BridgeTerminalKeyRail
|
||||
} from './bridge-terminal-key'
|
||||
|
||||
const ALL_RAIL_KEYS: BridgeTerminalKeyRail = [
|
||||
...BRIDGE_TERMINAL_TOOLBAR_KEY_IDS,
|
||||
...BRIDGE_TERMINAL_KEY_IDS,
|
||||
...BRIDGE_TERMINAL_MODIFIER_IDS,
|
||||
'char:/',
|
||||
'char:@'
|
||||
]
|
||||
|
||||
describe('the iPhone Duo key rail over the bridge', () => {
|
||||
it('publishes and delivers every rail key through both bridge ends', async () => {
|
||||
const pair = createFakeBridgePortPair({ terminalKeyRail: ALL_RAIL_KEYS })
|
||||
await pair.flush()
|
||||
expect(pair.client.getShellSession()?.terminalKeyRail).toEqual(ALL_RAIL_KEYS)
|
||||
const pressed: BridgeTerminalKeyId[] = []
|
||||
const release = pair.client.claimTerminalKeyRail((key) => pressed.push(key), 'all-keys')
|
||||
await pair.flush()
|
||||
for (const key of ALL_RAIL_KEYS) {
|
||||
expect(pair.host.sendTerminalKey(key, 'all-keys')).toBe(true)
|
||||
}
|
||||
await pair.flush()
|
||||
expect(pressed).toEqual(ALL_RAIL_KEYS)
|
||||
release()
|
||||
await pair.flush()
|
||||
expect(pair.host.sendTerminalKey('ctrlC', 'all-keys')).toBe(false)
|
||||
})
|
||||
it('tells the page which keys the rail holds, and moves that live as the window turns', async () => {
|
||||
const pair = createFakeBridgePortPair({ terminalKeyRail: ['escape', 'tab', 'enter'] })
|
||||
await pair.flush()
|
||||
expect(pair.client.getShellSession()?.terminalKeyRail).toEqual(['escape', 'tab', 'enter'])
|
||||
const seen: BridgeTerminalKeyRail[] = []
|
||||
pair.client.onTerminalKeyRailUpdate((keys) => seen.push(keys))
|
||||
|
||||
pair.host.publishTerminalKeyRail(['escape'])
|
||||
await pair.flush()
|
||||
pair.host.publishTerminalKeyRail(['escape'])
|
||||
await pair.flush()
|
||||
pair.host.publishTerminalKeyRail([])
|
||||
await pair.flush()
|
||||
expect(pair.client.getShellSession()?.terminalKeyRail).toEqual([])
|
||||
expect(seen).toEqual([['escape'], []])
|
||||
})
|
||||
|
||||
it('hears the page claim the rail, and drops the claim when a document starts or ends', async () => {
|
||||
const pair = createFakeBridgePortPair({ terminalKeyRail: ['escape', 'arrowUp'] })
|
||||
await pair.flush()
|
||||
const pressed: BridgeTerminalKeyId[] = []
|
||||
const release = pair.client.claimTerminalKeyRail((key) => pressed.push(key))
|
||||
await pair.flush()
|
||||
expect(pair.terminalKeyRailClaims.at(-1)).toBe(true)
|
||||
|
||||
expect(pair.host.sendTerminalKey('escape')).toBe(true)
|
||||
expect(pair.host.sendTerminalKey('arrowUp')).toBe(true)
|
||||
await pair.flush()
|
||||
expect(pressed).toEqual(['escape', 'arrowUp'])
|
||||
|
||||
// A re-asked `ready` drops the claim, and the page still holding one claims again on its `init`.
|
||||
pair.host.receive(JSON.stringify({ v: BRIDGE_PROTOCOL_VERSION, type: 'ready' }))
|
||||
await pair.flush()
|
||||
expect(pair.terminalKeyRailClaims.slice(-2)).toEqual([false, true])
|
||||
|
||||
release()
|
||||
await pair.flush()
|
||||
expect(pair.terminalKeyRailClaims.at(-1)).toBe(false)
|
||||
})
|
||||
|
||||
it('sends nothing either way with a shell that did not grant it, which is every older one', async () => {
|
||||
const pair = createFakeBridgePortPair({
|
||||
routeGrants: MOBILE_WEB_SHELL_GRANTS.filter(
|
||||
(grant) => grant !== BRIDGE_TERMINAL_KEY_RAIL_GRANT
|
||||
)
|
||||
})
|
||||
await pair.flush()
|
||||
const release = pair.client.claimTerminalKeyRail(() => {})
|
||||
await pair.flush()
|
||||
release()
|
||||
await pair.flush()
|
||||
expect(pair.toShell.some((json) => json.includes(BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY))).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
const before = pair.readToPage().length
|
||||
expect(pair.host.sendTerminalKey('enter')).toBe(false)
|
||||
await pair.flush()
|
||||
expect(pair.readToPage()).toHaveLength(before)
|
||||
})
|
||||
it('drops queued old-owner presses and rejects held repeats after focus moves', async () => {
|
||||
const pair = createFakeBridgePortPair()
|
||||
await pair.flush()
|
||||
const oldKeys: BridgeTerminalKeyId[] = []
|
||||
const newKeys: BridgeTerminalKeyId[] = []
|
||||
const release = pair.client.claimTerminalKeyRail((key) => oldKeys.push(key), 'pane-a-1')
|
||||
await pair.flush()
|
||||
expect(pair.host.sendTerminalKey('arrowUp', 'pane-a-1')).toBe(true)
|
||||
release()
|
||||
pair.client.claimTerminalKeyRail((key) => newKeys.push(key), 'pane-b-2')
|
||||
await pair.flush()
|
||||
expect(oldKeys).toEqual([])
|
||||
expect(newKeys).toEqual([])
|
||||
expect(pair.host.sendTerminalKey('arrowUp', 'pane-a-1')).toBe(false)
|
||||
expect(pair.host.sendTerminalKey('arrowUp', 'pane-b-2')).toBe(true)
|
||||
await pair.flush()
|
||||
expect(newKeys).toEqual(['arrowUp'])
|
||||
})
|
||||
})
|
||||
|
||||
it('carries toolbar state across both bridge ends and delivers toolbar actions only to the current owner', async () => {
|
||||
const pair = createFakeBridgePortPair()
|
||||
await pair.flush()
|
||||
const pressed: string[] = []
|
||||
const toolbar = {
|
||||
canSend: true,
|
||||
canCompose: true,
|
||||
keyboardVisible: false,
|
||||
liveInputEnabled: true,
|
||||
phoneDisplayMode: false,
|
||||
runsAgent: false,
|
||||
modifiers: { ctrl: 'once', alt: 'off', shift: 'locked' } as const
|
||||
}
|
||||
const release = pair.client.claimTerminalKeyRail(
|
||||
(key) => pressed.push(key),
|
||||
'toolbar-owner',
|
||||
toolbar
|
||||
)
|
||||
await pair.flush()
|
||||
expect(
|
||||
pair.toShell
|
||||
.map((json) => JSON.parse(json))
|
||||
.findLast((frame) => frame.name === BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY)?.toolbar
|
||||
).toEqual(toolbar)
|
||||
expect(pair.host.sendTerminalKey('toggleKeyboard', 'toolbar-owner')).toBe(true)
|
||||
expect(pair.host.sendTerminalKey('moreKeys', 'toolbar-owner')).toBe(true)
|
||||
await pair.flush()
|
||||
expect(pressed).toEqual(['toggleKeyboard', 'moreKeys'])
|
||||
const updateStart = pair.toShell.length
|
||||
release.update('toolbar-owner', { ...toolbar, keyboardVisible: true })
|
||||
await pair.flush()
|
||||
expect(
|
||||
pair.toShell
|
||||
.slice(updateStart)
|
||||
.map((json) => JSON.parse(json))
|
||||
.filter((frame) => frame.name === BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY)
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
claimed: true,
|
||||
inputOwner: 'toolbar-owner',
|
||||
toolbar: { ...toolbar, keyboardVisible: true }
|
||||
})
|
||||
])
|
||||
release()
|
||||
await pair.flush()
|
||||
expect(pair.host.sendTerminalKey('toggleKeyboard', 'toolbar-owner')).toBe(false)
|
||||
const releaseEnd = pair.toShell.length
|
||||
release.update('toolbar-owner', toolbar)
|
||||
await pair.flush()
|
||||
expect(pair.toShell).toHaveLength(releaseEnd)
|
||||
})
|
||||
|
||||
it('accepts only single printable characters and known latches', () => {
|
||||
expect(BridgeTerminalKeyIdSchema.safeParse('char:~').success).toBe(true)
|
||||
expect(BridgeTerminalKeyIdSchema.safeParse('char:ab').success).toBe(false)
|
||||
expect(BridgeTerminalKeyIdSchema.safeParse('char: ').success).toBe(false)
|
||||
const toolbar = {
|
||||
canSend: true,
|
||||
canCompose: true,
|
||||
keyboardVisible: false,
|
||||
liveInputEnabled: true,
|
||||
phoneDisplayMode: false,
|
||||
runsAgent: true,
|
||||
modifiers: { ctrl: 'off', alt: 'off', shift: 'off' }
|
||||
}
|
||||
expect(BridgeTerminalToolbarSchema.parse(toolbar)).toEqual(toolbar)
|
||||
expect(
|
||||
BridgeTerminalToolbarSchema.safeParse({
|
||||
...toolbar,
|
||||
modifiers: { ...toolbar.modifiers, ctrl: 'on' }
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/** The iPhone Duo key rail: terminal keys the shell draws in a side inset strip for the page. */
|
||||
|
||||
/**
|
||||
* The grant a session page declares, optionally, when it takes key presses from the shell's rail
|
||||
* and drops its own row of those keys while the rail shows. An older page declares none, so the
|
||||
* shell never draws a rail for it.
|
||||
*/
|
||||
export const BRIDGE_TERMINAL_KEY_RAIL_GRANT = 'terminalKeyRail'
|
||||
|
||||
/**
|
||||
* Page to shell, a notify on that grant: a terminal and its key row are on screen, or no longer.
|
||||
* The shell draws the rail only while this holds, so it never sends keys into a hidden terminal.
|
||||
*/
|
||||
export const BRIDGE_TERMINAL_KEY_RAIL_CLAIM_NOTIFY = 'terminal-key-rail-claim'
|
||||
|
||||
/** Shell to page, a frame: one press of a key on the rail. */
|
||||
export const BRIDGE_TERMINAL_KEY_FRAME = 'terminalKey'
|
||||
|
||||
/** Terminal keys the rail can send; the page applies its held modifiers, as its own key row does. */
|
||||
export const BRIDGE_TERMINAL_KEY_IDS = [
|
||||
'escape',
|
||||
'tab',
|
||||
'shiftTab',
|
||||
'arrowUp',
|
||||
'arrowDown',
|
||||
'arrowLeft',
|
||||
'arrowRight',
|
||||
'enter',
|
||||
'ctrlC'
|
||||
] as const
|
||||
|
||||
/** Sticky modifiers: a tap applies to the next key, a double tap keeps it on. */
|
||||
export const BRIDGE_TERMINAL_MODIFIER_IDS = ['ctrl', 'alt', 'shift'] as const
|
||||
|
||||
export const BRIDGE_TERMINAL_TOOLBAR_KEY_IDS = [
|
||||
'toggleKeyboard',
|
||||
'toggleDisplayMode',
|
||||
'toggleInputMode',
|
||||
'moreKeys'
|
||||
] as const
|
||||
|
||||
const StickyModifierLatchSchema = z.enum(['off', 'once', 'locked'])
|
||||
|
||||
export const BridgeTerminalToolbarSchema = z.object({
|
||||
canSend: z.boolean(),
|
||||
canCompose: z.boolean(),
|
||||
keyboardVisible: z.boolean(),
|
||||
liveInputEnabled: z.boolean(),
|
||||
phoneDisplayMode: z.boolean(),
|
||||
/** Agent tabs get the agent row (⇧tab, ⏎, ^C), shells the shell row, as the page's own row does. */
|
||||
runsAgent: z.boolean(),
|
||||
modifiers: z.object({
|
||||
ctrl: StickyModifierLatchSchema,
|
||||
alt: StickyModifierLatchSchema,
|
||||
shift: StickyModifierLatchSchema
|
||||
}),
|
||||
directionPadEnabled: z.boolean().optional()
|
||||
})
|
||||
export type BridgeTerminalToolbar = z.infer<typeof BridgeTerminalToolbarSchema>
|
||||
|
||||
export function sameTerminalKeyRailToolbar(
|
||||
a?: BridgeTerminalToolbar,
|
||||
b?: BridgeTerminalToolbar
|
||||
): boolean {
|
||||
if (!a || !b) {
|
||||
return a === b
|
||||
}
|
||||
return (
|
||||
a.canSend === b.canSend &&
|
||||
a.canCompose === b.canCompose &&
|
||||
a.keyboardVisible === b.keyboardVisible &&
|
||||
a.liveInputEnabled === b.liveInputEnabled &&
|
||||
a.phoneDisplayMode === b.phoneDisplayMode &&
|
||||
a.runsAgent === b.runsAgent &&
|
||||
a.modifiers.ctrl === b.modifiers.ctrl &&
|
||||
a.modifiers.alt === b.modifiers.alt &&
|
||||
a.modifiers.shift === b.modifiers.shift &&
|
||||
a.directionPadEnabled === b.directionPadEnabled
|
||||
)
|
||||
}
|
||||
|
||||
export const BridgeTerminalKeyIdSchema = z.union([
|
||||
z.enum(BRIDGE_TERMINAL_KEY_IDS),
|
||||
z.enum(BRIDGE_TERMINAL_MODIFIER_IDS),
|
||||
z.enum(BRIDGE_TERMINAL_TOOLBAR_KEY_IDS),
|
||||
// One printable character, typed with the held modifiers.
|
||||
z.custom<`char:${string}`>(
|
||||
(value) => typeof value === 'string' && /^char:[\x21-\x7e]$/.test(value)
|
||||
)
|
||||
])
|
||||
export type BridgeTerminalKeyId = z.infer<typeof BridgeTerminalKeyIdSchema>
|
||||
|
||||
/**
|
||||
* The keys the scrollable rail shows beside the page, in its order. Empty or absent while
|
||||
* no rail shows, including every moment the page holds no claim.
|
||||
*/
|
||||
export const BridgeTerminalKeyRailSchema = z.array(BridgeTerminalKeyIdSchema)
|
||||
|
||||
export type BridgeTerminalKeyRail = readonly BridgeTerminalKeyId[]
|
||||
|
||||
export function sameTerminalKeyRail(a: BridgeTerminalKeyRail, b: BridgeTerminalKeyRail): boolean {
|
||||
return a.length === b.length && a.every((key, index) => key === b[index])
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
TERMINAL_SHORTCUT_STORAGE_KEYS,
|
||||
isHostWorkspaceStorageKey,
|
||||
isPageStorageKeyForRoute,
|
||||
PAGE_STORAGE_MAX_VALUE_CHARS
|
||||
@@ -58,6 +59,28 @@ export function publishPageStorage(
|
||||
readWorkspaceKey = forWorkspaceKeyReader
|
||||
}
|
||||
|
||||
/** Apply native shortcut settings without replacing page-owned drafts or pending journals. */
|
||||
export function refreshPageTerminalShortcuts(
|
||||
entries: Readonly<Record<string, string>>,
|
||||
oversize: readonly string[]
|
||||
): void {
|
||||
const nextOversize = new Set(oversizeKeys)
|
||||
for (const key of TERMINAL_SHORTCUT_STORAGE_KEYS) {
|
||||
const value = entries[key]
|
||||
if (value === undefined) {
|
||||
values.delete(key)
|
||||
} else {
|
||||
values.set(key, value)
|
||||
}
|
||||
if (oversize.includes(key)) {
|
||||
nextOversize.add(key)
|
||||
} else {
|
||||
nextOversize.delete(key)
|
||||
}
|
||||
}
|
||||
oversizeKeys = nextOversize
|
||||
}
|
||||
|
||||
function getItem(key: string): Promise<string | null> {
|
||||
return readWorkspaceKey !== null && isHostWorkspaceStorageKey(key, hostId)
|
||||
? readWorkspaceKey(key)
|
||||
|
||||
@@ -78,16 +78,9 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
|
||||
default: { getItem: async () => null, setItem: async () => {} }
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({ X: 'Icon' }))
|
||||
vi.mock('react-native-safe-area-context', async () => {
|
||||
const { createContext } = await import('react')
|
||||
return {
|
||||
useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 }),
|
||||
useSafeAreaFrame: () => ({ x: 0, y: 0, width: 390, height: 844 }),
|
||||
SafeAreaFrameContext: createContext(null),
|
||||
SafeAreaInsetsContext: createContext(null),
|
||||
SafeAreaProvider: ({ children }: { children: unknown }) => children
|
||||
}
|
||||
})
|
||||
vi.mock('react-native-safe-area-context', () => ({
|
||||
useSafeAreaInsets: () => ({ bottom: 8, left: 0, right: 0, top: 44 })
|
||||
}))
|
||||
vi.mock('expo-router', () => ({
|
||||
router: { replace: vi.fn() },
|
||||
useLocalSearchParams: () => dependencies.params,
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodedRouteHref } from './encoded-route-href'
|
||||
|
||||
describe('encodedRouteHref', () => {
|
||||
it('keeps a worktree id with slashes inside its own segment', () => {
|
||||
expect(
|
||||
encodedRouteHref(['h', '[hostId]', 'session', '[worktreeId]'], {
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'repo::/private/tmp/wt/fold-layout'
|
||||
})
|
||||
).toBe('/h/host-1/session/repo%3A%3A%2Fprivate%2Ftmp%2Fwt%2Ffold-layout')
|
||||
})
|
||||
|
||||
it('drops groups and spreads catch-all values', () => {
|
||||
expect(
|
||||
encodedRouteHref(['(app)', 'h', '[hostId]', '[...page]'], {
|
||||
hostId: 'h 1',
|
||||
page: ['a', 'b/c']
|
||||
})
|
||||
).toBe('/h/h%201/a/b%2Fc')
|
||||
})
|
||||
})
|
||||
@@ -1,19 +0,0 @@
|
||||
type RouteParams = Readonly<Record<string, string | string[] | undefined>>
|
||||
|
||||
// expo-router's pathname joins raw param values; a worktree id holds slashes, so encode each dynamic segment or the native router matches another screen.
|
||||
export function encodedRouteHref(segments: readonly string[], params: RouteParams): string {
|
||||
const parts = segments.flatMap((segment) => {
|
||||
if (segment.startsWith('(') && segment.endsWith(')')) {
|
||||
return []
|
||||
}
|
||||
const rest = /^\[\.\.\.(.+)\]$/.exec(segment)?.[1]
|
||||
const name = rest ?? /^\[(.+)\]$/.exec(segment)?.[1]
|
||||
if (name === undefined) {
|
||||
return [segment]
|
||||
}
|
||||
const value = params[name]
|
||||
const values = value === undefined ? [] : Array.isArray(value) ? value : [value]
|
||||
return values.map(encodeURIComponent)
|
||||
})
|
||||
return `/${parts.join('/')}`
|
||||
}
|
||||
@@ -25,9 +25,6 @@ vi.mock('../session/mobile-session-styles', () => ({ styles: { container: {}, ka
|
||||
vi.mock('../session/MobileSessionHeader', () => ({ MobileSessionHeader: () => null }))
|
||||
vi.mock('../session/MobileSessionContentRow', () => ({ MobileSessionContentRow: () => null }))
|
||||
vi.mock('../session/MobileSessionSheets', () => ({ MobileSessionSheets: () => null }))
|
||||
vi.mock('../session/session-side-rail', () => ({
|
||||
useSessionSideRail: () => ({ backInRail: false, keysInRail: false })
|
||||
}))
|
||||
vi.mock('expo-router', async () => {
|
||||
const { useEffect } = await import('react')
|
||||
return { useFocusEffect: (effect: () => () => void) => useEffect(effect, [effect]) }
|
||||
|
||||
@@ -87,12 +87,13 @@ const store: GenerationStore = {
|
||||
forgetHostUpdateFailures: async () => undefined
|
||||
}
|
||||
|
||||
function routes(declares: boolean): MobileWebBundleManifestRead['routes'] {
|
||||
function routes(declares: boolean, acrossLayouts = true): MobileWebBundleManifestRead['routes'] {
|
||||
return [
|
||||
{
|
||||
pathname: '/h/[hostId]',
|
||||
grants: ['navigate'],
|
||||
...(declares ? { canOwnHostArea: true } : {})
|
||||
...(declares ? { canOwnHostArea: true } : {}),
|
||||
...(declares && acrossLayouts ? { canOwnHostAreaAcrossLayouts: true } : {})
|
||||
},
|
||||
{ pathname: '/h/[hostId]/session/[worktreeId]', grants: ['navigate'] }
|
||||
]
|
||||
@@ -101,9 +102,10 @@ function routes(declares: boolean): MobileWebBundleManifestRead['routes'] {
|
||||
async function settledKinds(
|
||||
declares: boolean,
|
||||
widths: readonly boolean[],
|
||||
routePathname = '/h/host-1/session/wt-1'
|
||||
routePathname = '/h/host-1/session/wt-1',
|
||||
acrossLayouts = true
|
||||
) {
|
||||
doubles.routes = routes(declares)
|
||||
doubles.routes = routes(declares, acrossLayouts)
|
||||
doubles.manifestReads = 0
|
||||
doubles.gates.hostCapabilities = [MOBILE_WEB_BUNDLE_CAPABILITY]
|
||||
const seen: MobileWebShellSessionState[] = []
|
||||
@@ -142,9 +144,16 @@ async function settledKinds(
|
||||
}
|
||||
|
||||
describe('a detail session across a layout-class change', () => {
|
||||
// Each layout flip must reconsider which host-route hops the page may keep.
|
||||
it('restarts an open page once per flip under a declaring bundle', async () => {
|
||||
// Ownership and route grants belong to the document, not its changing viewport.
|
||||
it('preserves the open page across rotation under a declaring bundle', async () => {
|
||||
expect(await settledKinds(true, [false, true, false])).toEqual({
|
||||
kinds: ['ready session-1', 'ready session-1', 'ready session-1'],
|
||||
manifestReads: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps legacy declaring pages usable under their original layout-specific sessions', async () => {
|
||||
expect(await settledKinds(true, [false, true, false], '/h/host-1', false)).toEqual({
|
||||
kinds: ['ready session-1', 'ready session-2', 'ready session-3'],
|
||||
manifestReads: 3
|
||||
})
|
||||
@@ -157,9 +166,9 @@ describe('a detail session across a layout-class change', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('rebuilds the host route, whose wide session owns the area under other grants', async () => {
|
||||
const { kinds } = await settledKinds(true, [false, true], '/h/host-1')
|
||||
expect(kinds).toEqual(['ready session-1', 'ready session-2'])
|
||||
it('preserves the host-area document and its in-page workspace across rotation', async () => {
|
||||
const { kinds } = await settledKinds(true, [false, true, false], '/h/host-1')
|
||||
expect(kinds).toEqual(['ready session-1', 'ready session-1', 'ready session-1'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -145,6 +145,17 @@ describe('leaving a wide detail session for its host route', () => {
|
||||
})
|
||||
|
||||
describe('the same hop on a phone', () => {
|
||||
it.each([HOST, BOUNCE])('replaces a cold detail session with %s', async (href) => {
|
||||
await hopFromSession(PHONE, [DETAIL], href)
|
||||
expect(applied([DETAIL])).toEqual([href])
|
||||
expect(dependencies.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates the existing host notice without retaining the exited detail session', async () => {
|
||||
await hopFromSession(PHONE, [HOST, DETAIL], BOUNCE)
|
||||
expect(applied([HOST, DETAIL])).toEqual([BOUNCE])
|
||||
})
|
||||
|
||||
it('returns to the host route below rather than stacking a second one', async () => {
|
||||
await hopFromSession(PHONE, [HOST, DETAIL], HOST)
|
||||
expect(device.dismissTo).not.toHaveBeenCalled()
|
||||
|
||||
@@ -100,6 +100,49 @@ describe('the page declaring it can own the host area', () => {
|
||||
const unserved = [{ ...desktopRoutes(true)[0]!, grants: ['native.teleport.start'] }]
|
||||
expect(routeViewOf(unserved, HOST_ROUTE, true).ownsHostArea).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the area at either width only for a page that declares it across layouts', () => {
|
||||
const [host, ...rest] = desktopRoutes(true)
|
||||
const routes = [{ ...host!, canOwnHostAreaAcrossLayouts: true }, ...rest]
|
||||
expect(routeViewOf(routes, HOST_ROUTE, true).ownsHostArea).toBe(true)
|
||||
expect(routeViewOf(routes, HOST_ROUTE, false).ownsHostArea).toBe(true)
|
||||
// A narrow detail session hands a hop to the host route back to the shell, like a wide one.
|
||||
expect(
|
||||
routeViewOf(routes, '/h/host-1/session/wt-1', false).pageRouteGrants.map((p) => p.pathname)
|
||||
).not.toContain('/h/[hostId]')
|
||||
// Without `canOwnHostArea` the across-layouts flag claims nothing.
|
||||
const flagOnly = [{ ...desktopRoutes(false)[0]!, canOwnHostAreaAcrossLayouts: true }]
|
||||
expect(routeViewOf(flagOnly, HOST_ROUTE, false).ownsHostArea).toBe(false)
|
||||
expect(routeViewOf(flagOnly, HOST_ROUTE, true).ownsHostArea).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['/h/[hostId]', '/h/[...page]'])(
|
||||
'does not combine ownership declarations from a separate matching %s route',
|
||||
(acrossLayoutsPattern) => {
|
||||
const [host, ...details] = desktopRoutes(true)
|
||||
const routes = [
|
||||
host!,
|
||||
{
|
||||
pathname: acrossLayoutsPattern,
|
||||
grants: ['navigate'],
|
||||
canOwnHostAreaAcrossLayouts: true
|
||||
},
|
||||
...details
|
||||
]
|
||||
const parsed = MobileWebBundleManifestReadSchema.parse({
|
||||
...MANIFEST_WIRE,
|
||||
buildId: computeMobileWebBundleId(MANIFEST_WIRE.assets),
|
||||
routes
|
||||
})
|
||||
expect(routeViewOf(parsed.routes, HOST_ROUTE, false).ownsHostArea).toBe(false)
|
||||
expect(routeViewOf(parsed.routes, HOST_ROUTE, true).ownsHostArea).toBe(true)
|
||||
expect(
|
||||
routeViewOf(parsed.routes, '/h/host-1/session/wt-1', false).pageRouteGrants.map(
|
||||
(pair) => pair.pathname
|
||||
)
|
||||
).toContain('/h/[hostId]')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('the wide host-area session', () => {
|
||||
|
||||
@@ -81,6 +81,7 @@ vi.mock('../theme/mobile-theme', async (original) => ({
|
||||
colors: {}
|
||||
}))
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
HOST_SIDEBAR_DEFAULT_WIDTH: 320,
|
||||
HOST_SIDEBAR_MAX_WIDTH: 480,
|
||||
HOST_SIDEBAR_MIN_WIDTH: 240,
|
||||
loadHostSidebarWidth: async () => 320,
|
||||
@@ -173,7 +174,7 @@ function pageRoutes(page: Page): MobileWebPageRoute[] | null {
|
||||
{
|
||||
pathname: '/h/[hostId]',
|
||||
grants: ['navigate'],
|
||||
...(page === 'declared' ? { canOwnHostArea: true } : {})
|
||||
...(page === 'declared' ? { canOwnHostArea: true, canOwnHostAreaAcrossLayouts: true } : {})
|
||||
},
|
||||
{ pathname: '/h/[hostId]/session/[worktreeId]', grants: ['navigate'] }
|
||||
]
|
||||
@@ -346,11 +347,11 @@ describe('the page sidebar rule', () => {
|
||||
})
|
||||
|
||||
describe('a phone', () => {
|
||||
it('reports nothing to the layout from a served host route', async () => {
|
||||
it('reports host ownership without drawing a sidebar on a narrow viewport', async () => {
|
||||
const hostSession = sessionFor('declared', HOST, false)
|
||||
expect(hostSession.state.kind).toBe('activating')
|
||||
env.reports.length = 0
|
||||
expect(await sidebarsIn('native', PHONE, { hostSession })).toBe(0)
|
||||
expect(env.reports).toEqual([])
|
||||
expect(env.reports).toEqual([true, false])
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user