Skip to content

Commit e61fd4d

Browse files
yyq1025claude
andcommitted
app: update-required route gate + glass CTA house style; menubar: periodic update checks
Protocol-mismatch gate (app side of the outdatedSide work): - provider: sticky protocolError record — set on IncompatibleProtocolError, cleared only on successful handshake or unpair, so the gate survives retry windows without flickering back to the main UI - (main)/_layout: third Stack.Protected branch routes to /update-required while blocked; terminal mismatch is stable, so gating the whole surface doesn't violate the identity-axis rule (which protects against TRANSIENT health states flipping routes) - new update-required screen: copy + primary action branch on outdatedSide — "app" gets an update link (+ Retry secondary), "daemon" gets Retry + Mac menu hint, "unknown" falls back to neutral copy; "Forget this Mac" escape hatch so a user can re-pair a different Mac CTA house style (all three plain-background CTA screens unified): - swift-ui Button + buttonStyle("glassProminent") primary + buttonStyle("bordered") secondary, replacing universal Button variants. Universal Button prepends a variant-derived buttonStyle at modifier index 0 (SwiftUI-innermost wins), silently eating user buttonStyle modifiers — glass styles can't be expressed through it. Plain "glass" secondaries rejected: nothing to refract on flat content-layer backgrounds, reads as a barely-visible frosted capsule. - pair: disabled prop → disabled() modifier (swift-ui Button has no prop) menubar: re-check updates every 6h (was startup-only) — keeps a weeks-running menu bar app from drifting behind an auto-updating iOS app, which is exactly the window that produces the update-required gate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ef760e5 commit e61fd4d

6 files changed

Lines changed: 301 additions & 31 deletions

File tree

packages/app/src/app/(main)/_layout.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { useDaemonClient } from "@/lib/daemon-client-context";
1919
* probes and the drawer routes all stay at the same paths.
2020
*/
2121
export default function MainLayout() {
22-
const { isUnpaired } = useDaemonClient();
22+
const { isUnpaired, isProtocolBlocked } = useDaemonClient();
2323
const scheme = useColorScheme() ?? "light";
2424
return (
2525
<Stack screenOptions={{ headerShown: false }}>
@@ -28,7 +28,17 @@ export default function MainLayout() {
2828
in-app QR scanner and paste-payload fallback. */}
2929
<Stack.Screen name="onboarding" />
3030
</Stack.Protected>
31-
<Stack.Protected guard={!isUnpaired}>
31+
<Stack.Protected guard={!isUnpaired && isProtocolBlocked}>
32+
{/* Protocol-mismatch gate. A version mismatch is TERMINAL (no
33+
retry fixes it) and the main UI is a dead shell behind it
34+
(every RPC would hang), so the whole surface gates rather
35+
than badges. This does NOT violate the identity-axis rule —
36+
that rule keeps TRANSIENT health states (connecting/offline)
37+
off the route gate; a terminal mismatch is stable until the
38+
user updates, retries successfully, or unpairs. */}
39+
<Stack.Screen name="update-required" />
40+
</Stack.Protected>
41+
<Stack.Protected guard={!isUnpaired && !isProtocolBlocked}>
3242
{/* (drawer) is a route group hosting the main app: Drawer with
3343
custom session-list sidebar, plus the new-session create page
3444
(index) and session detail. */}

packages/app/src/app/(main)/onboarding.tsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
1-
import { Button, Column, Host, Text as UIText } from "@expo/ui";
2-
import { controlSize, frame, tint } from "@expo/ui/swift-ui/modifiers";
1+
// Button from swift-ui, not universal: universal Button's variant prop
2+
// injects a buttonStyle at modifier index 0 (SwiftUI-innermost, wins),
3+
// so glass styles can't be expressed through it. House CTA style is
4+
// `glassProminent` primary + `bordered` secondary (plain `glass` is
5+
// near-invisible on flat content-layer backgrounds). Host/Column/Text
6+
// stay universal — thin swift-ui wrappers, same native tree.
7+
import { Column, Host, Text as UIText } from "@expo/ui";
8+
import { Button } from "@expo/ui/swift-ui";
9+
import {
10+
buttonStyle,
11+
controlSize,
12+
frame,
13+
tint,
14+
} from "@expo/ui/swift-ui/modifiers";
315
import { CameraView, useCameraPermissions } from "expo-camera";
416
import { router, Stack } from "expo-router";
517
import { useCallback, useRef, useState } from "react";
@@ -26,9 +38,10 @@ import { SidecodeMark } from "@/components/sidecode-mark";
2638
*
2739
* UI is hybrid: RN owns the layout, text, brand mark, and the footer link
2840
* (flexbox + uniwind `dark:` theming, `Pressable`/`Image` do links + logos
29-
* naturally). ONLY the CTA bar is an `@expo/ui` (universal) Host, so the
30-
* Buttons render the native iOS 26 control look (Liquid Glass) that RN can't
31-
* reproduce — plain `variant="filled"/"outlined"`, no explicit buttonStyle.
41+
* naturally). ONLY the CTA bar is an `@expo/ui` Host island, so the
42+
* Buttons render the native iOS 26 control look that RN can't reproduce —
43+
* house style `buttonStyle("glassProminent")` primary + `"bordered"`
44+
* secondary (see import note).
3245
* The Host self-sizes its height via `matchContents={{ vertical: true }}`
3346
* (effective on iOS) and `alignSelf:"stretch"` gives it RN's full width;
3447
* `ignoreSafeArea="keyboard"` stops the Alert.prompt keyboard from shoving
@@ -158,9 +171,12 @@ export default function OnboardingRoute() {
158171
>
159172
<Column spacing={8}>
160173
<Button
161-
variant="filled"
162174
onPress={handleScan}
163-
modifiers={[controlSize("extraLarge"), tint("#EE5722")]}
175+
modifiers={[
176+
buttonStyle("glassProminent"),
177+
controlSize("extraLarge"),
178+
tint("#EE5722"),
179+
]}
164180
>
165181
<UIText
166182
textStyle={{
@@ -173,9 +189,12 @@ export default function OnboardingRoute() {
173189
</UIText>
174190
</Button>
175191
<Button
176-
variant="outlined"
177192
onPress={handlePaste}
178-
modifiers={[controlSize("extraLarge"), tint("#EE5722")]}
193+
modifiers={[
194+
buttonStyle("bordered"),
195+
controlSize("extraLarge"),
196+
tint("#EE5722"),
197+
]}
179198
>
180199
<UIText
181200
textStyle={{
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// Button comes from the swift-ui entry, NOT universal: universal Button
2+
// prepends a variant-derived `buttonStyle()` at modifier index 0, and
3+
// SwiftUI's innermost buttonStyle wins — a user-passed
4+
// `buttonStyle("glassProminent")` is silently inert there. swift-ui
5+
// Button injects nothing, so ours is the only one. Host/Column/Icon/Text
6+
// stay universal: their iOS impls are thin swift-ui wrappers (Host is a
7+
// literal re-export), so the mixed tree is native-identical.
8+
import { Column, Host, Icon, Text as UIText } from "@expo/ui";
9+
import { Button } from "@expo/ui/swift-ui";
10+
import {
11+
buttonStyle,
12+
controlSize,
13+
frame,
14+
tint,
15+
} from "@expo/ui/swift-ui/modifiers";
16+
import { useCallback } from "react";
17+
import { Alert, Linking, Pressable, Text, View } from "react-native";
18+
import { useDaemonClient } from "@/lib/daemon-client-context";
19+
20+
// Website redirect, not a hardcoded App Store id — the site can point at
21+
// TestFlight today and the store listing later without an app release.
22+
const IOS_UPDATE_URL = "https://sidecode.app/ios";
23+
24+
/**
25+
* `/update-required` — protocol-mismatch gate (only reachable while
26+
* `isProtocolBlocked` is true; see `(main)/_layout.tsx` for why this is
27+
* a route gate and not a banner).
28+
*
29+
* One route for both directions in V0; the copy + primary action branch
30+
* on `protocolError.outdatedSide`:
31+
*
32+
* - `"app"` → this app is too old. Primary = open the update page
33+
* (the one direction we can never self-heal — iOS apps can't update
34+
* themselves). Conceptually this branch is the screen's terminal
35+
* identity: in a multi-daemon future, the `"daemon"` branch leaves
36+
* the route gate (per-daemon badge + daemon self-update) and this
37+
* route narrows to the app-outdated force-update screen.
38+
* - `"daemon"` → the Mac app is too old. Primary = Try Again, for
39+
* after the user updates the Mac side (menu bar → Check for
40+
* updates). Becomes a transient "Mac is updating…" state once
41+
* daemon self-update-on-mismatch lands.
42+
* - `"unknown"` → no usable daemon version (shouldn't happen against
43+
* a real daemon) — neutral both-sides copy, Retry.
44+
*
45+
* Layout follows the onboarding idiom: RN owns layout/text/theming,
46+
* the bottom CTA bar is the only `@expo/ui` Host island so the buttons
47+
* get the native iOS control look.
48+
*/
49+
export default function UpdateRequiredRoute() {
50+
const { protocolError, paired, isLoading, reset, unpair } = useDaemonClient();
51+
52+
const handleRetry = useCallback(() => reset(), [reset]);
53+
54+
const handleOpenUpdatePage = useCallback(() => {
55+
void Linking.openURL(IOS_UPDATE_URL);
56+
}, []);
57+
58+
const handleForget = useCallback(() => {
59+
Alert.alert(
60+
"Forget this Mac?",
61+
"You'll need to scan its QR code again to reconnect.",
62+
[
63+
{ text: "Cancel", style: "cancel" },
64+
{ text: "Forget", style: "destructive", onPress: () => void unpair() },
65+
],
66+
);
67+
}, [unpair]);
68+
69+
// Guard-unmount race: the route can render one frame after a successful
70+
// retry cleared the error. Render nothing; the gate is about to flip.
71+
if (!protocolError) return null;
72+
73+
const side = protocolError.outdatedSide;
74+
const serviceName = paired?.serviceName;
75+
76+
return (
77+
<View className="flex-1 bg-white dark:bg-black px-6 pb-safe-offset-4 pt-safe">
78+
<View className="flex-1 justify-center">
79+
<Host matchContents style={{ alignSelf: "flex-start" }}>
80+
<Icon name="arrow.up.circle" size={56} color="#EE5722" />
81+
</Host>
82+
<Text className="mt-4 text-3xl font-bold text-black dark:text-white">
83+
Update Required
84+
</Text>
85+
<Text className="mt-2 text-base text-gray-500 dark:text-gray-400">
86+
{protocolError.message}
87+
</Text>
88+
{serviceName ? (
89+
<Text className="mt-1 text-sm text-gray-500 dark:text-gray-400">
90+
Paired with {serviceName}.
91+
</Text>
92+
) : null}
93+
{/* Diagnostics footnote — protocol (wire) versions, not marketing
94+
versions. Useful in a bug report screenshot. */}
95+
<Text className="mt-3 text-xs text-gray-400 dark:text-gray-500">
96+
App protocol {protocolError.appProtocolVersion} · Mac protocol{" "}
97+
{protocolError.daemonProtocolVersion ?? "unknown"}
98+
</Text>
99+
</View>
100+
101+
{side === "daemon" ? (
102+
<Text className="mb-3 text-sm text-gray-500 dark:text-gray-400 text-center">
103+
On your Mac: Sidecode menu → Check for updates.
104+
</Text>
105+
) : null}
106+
107+
<Host
108+
matchContents={{ vertical: true }}
109+
style={{ alignSelf: "stretch" }}
110+
ignoreSafeArea="keyboard"
111+
>
112+
<Column spacing={8}>
113+
{side === "app" ? (
114+
<Button
115+
onPress={handleOpenUpdatePage}
116+
modifiers={[
117+
buttonStyle("glassProminent"),
118+
controlSize("extraLarge"),
119+
tint("#EE5722"),
120+
]}
121+
>
122+
<UIText
123+
textStyle={{ fontWeight: "600", textAlign: "center" }}
124+
modifiers={[frame({ maxWidth: Infinity })]}
125+
>
126+
Get the Update
127+
</UIText>
128+
</Button>
129+
) : null}
130+
{/* Primary when it's the only action (daemon/unknown), secondary
131+
next to Get the Update (app). Secondary is `bordered`, not
132+
`glass`: plain glass has nothing to refract on this flat
133+
content-layer background and reads as a barely-visible frosted
134+
capsule (verified on-device; matches Apple's own practice —
135+
content-layer CTAs on plain backgrounds stay bordered/filled). */}
136+
<Button
137+
onPress={handleRetry}
138+
modifiers={[
139+
buttonStyle(side === "app" ? "bordered" : "glassProminent"),
140+
controlSize("extraLarge"),
141+
tint("#EE5722"),
142+
]}
143+
>
144+
<UIText
145+
textStyle={
146+
side === "app"
147+
? { textAlign: "center" }
148+
: { fontWeight: "600", textAlign: "center" }
149+
}
150+
modifiers={[frame({ maxWidth: Infinity })]}
151+
>
152+
{isLoading ? "Retrying…" : "Try Again"}
153+
</UIText>
154+
</Button>
155+
</Column>
156+
</Host>
157+
158+
{/* Escape hatch — without it a user who wants to pair a different
159+
(already-updated) Mac is soft-locked on this screen. */}
160+
<Pressable
161+
onPress={handleForget}
162+
hitSlop={8}
163+
className="mt-4 self-center"
164+
>
165+
<Text className="text-sm text-gray-500 dark:text-gray-400">
166+
Forget this Mac
167+
</Text>
168+
</Pressable>
169+
</View>
170+
);
171+
}

packages/app/src/app/pair.tsx

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
import { Button, Column, Host, Icon, Spacer, Text } from "@expo/ui";
2-
import { controlSize, frame, tint } from "@expo/ui/swift-ui/modifiers";
1+
// Button from swift-ui, not universal (universal's variant prop blocks
2+
// glass styles — its injected buttonStyle sits innermost and wins). House
3+
// CTA style: `glassProminent` primary + `bordered` secondary. Note
4+
// swift-ui Button has no `disabled` PROP — it's the `disabled()` modifier.
5+
import { Column, Host, Icon, Spacer, Text } from "@expo/ui";
6+
import { Button } from "@expo/ui/swift-ui";
7+
import {
8+
buttonStyle,
9+
controlSize,
10+
disabled,
11+
frame,
12+
tint,
13+
} from "@expo/ui/swift-ui/modifiers";
314
import { router, Stack, useLocalSearchParams } from "expo-router";
415
import { useMemo, useState } from "react";
516
import { decodePairOffer, type PairOffer } from "@/lib/daemon-client";
@@ -34,17 +45,17 @@ import { useDaemonClient } from "@/lib/daemon-client-context";
3445
* - Native iOS sheet header with X close button (top-right)
3546
* - Computer SF Symbol, title and body text top-leading
3647
* - Two full-width stacked buttons pinned to the bottom (primary
37-
* filled + secondary outlined)
48+
* glassProminent + secondary bordered)
3849
*
3950
* Implementation:
40-
* - Universal `@expo/ui` components (variant="filled"/"outlined" map
41-
* to SwiftUI .borderedProminent/.bordered on iOS; same shape will
42-
* render via Compose on Android once we add modifiers).
43-
* - SwiftUI escape hatch only where Universal can't reach: button
44-
* `controlSize("large")` and the `.frame(maxWidth: .infinity)` on
45-
* the label Text that triggers the "Button hugs label, so widen
46-
* label" SwiftUI idiom for full-width sheet CTAs.
47-
* - When Android lands (V0.5+), mirror the modifier paths via
51+
* - Universal `@expo/ui` components for layout/text; Buttons are
52+
* swift-ui with house CTA styles (glassProminent / bordered — see
53+
* import note).
54+
* - SwiftUI modifiers where Universal can't reach: `controlSize`,
55+
* `disabled`, and the `.frame(maxWidth: .infinity)` on the label
56+
* Text that triggers the "Button hugs label, so widen label"
57+
* SwiftUI idiom for full-width sheet CTAs.
58+
* - When Android lands (V0.5+), mirror via
4859
* `@expo/ui/jetpack-compose/modifiers` in a `pair.android.tsx`.
4960
*
5061
* Layout structure: single Host + Stack.Toolbar wrapping the route,
@@ -174,10 +185,13 @@ export default function PairModal() {
174185
modifiers={[frame({ maxWidth: Infinity, alignment: "topLeading" })]}
175186
>
176187
<Button
177-
variant="filled"
178188
onPress={primaryAction.onPress}
179-
disabled={primaryAction.disabled}
180-
modifiers={[controlSize("extraLarge"), tint("#EE5722")]}
189+
modifiers={[
190+
buttonStyle("glassProminent"),
191+
controlSize("extraLarge"),
192+
tint("#EE5722"),
193+
disabled(primaryAction.disabled === true),
194+
]}
181195
>
182196
<Text
183197
textStyle={{
@@ -192,10 +206,13 @@ export default function PairModal() {
192206
</Button>
193207
{secondary && (
194208
<Button
195-
variant="outlined"
196209
onPress={secondary.onPress}
197-
disabled={busy}
198-
modifiers={[controlSize("extraLarge"), tint("#EE5722")]}
210+
modifiers={[
211+
buttonStyle("bordered"),
212+
controlSize("extraLarge"),
213+
tint("#EE5722"),
214+
disabled(busy),
215+
]}
199216
>
200217
<Text
201218
textStyle={{ fontSize: 17, textAlign: "center" }}

0 commit comments

Comments
 (0)