Skip to content

Commit 61a021f

Browse files
committed
Harden mobile pairing and notifications
- Add QR pairing flow with clipboard paste and copy link support - Configure mobile notification permissions and background modes - Add tests for QR generation and mobile notification routing
1 parent 193f91e commit 61a021f

9 files changed

Lines changed: 497 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- CLI npm package name is `okcodes`. Install with `npm install -g okcodes`; the `okcode` binary name is unchanged.
1313

14+
## [0.0.13] - 2026-04-01
15+
16+
See [docs/releases/v0.0.13.md](docs/releases/v0.0.13.md) for full notes.
17+
18+
### Added
19+
20+
- Push notifications for approval requests, user-input requests, turn completions, and session errors on mobile.
21+
- QR code pairing flow: desktop shows scannable QR, mobile supports clipboard paste and auto-pair.
22+
- Token rotation and revocation model with short-lived pairing tokens.
23+
- Connection state banner for mobile companion (connecting, reconnecting, disconnected).
24+
- Android `POST_NOTIFICATIONS` and `SCHEDULE_EXACT_ALARM` permissions.
25+
- iOS `UIBackgroundModes` for background processing.
26+
- Capacitor `LocalNotifications` plugin configuration.
27+
- `GET /api/pairing` HTTP endpoint for short-lived pairing link generation.
28+
- WebSocket methods: `server.generatePairingLink`, `server.rotateToken`, `server.revokeToken`, `server.listTokens`.
29+
1430
## [0.0.12] - 2026-04-01
1531

1632
See [docs/releases/v0.0.12.md](docs/releases/v0.0.12.md) for full notes and [docs/releases/v0.0.12/assets.md](docs/releases/v0.0.12/assets.md) for release asset inventory.

apps/mobile/android/app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,8 @@
4545
<!-- Permissions -->
4646

4747
<uses-permission android:name="android.permission.INTERNET" />
48+
<!-- Required on Android 13+ (API 33) to display local notifications. -->
49+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
50+
<!-- Keep a minimal wake-lock so scheduled notifications fire reliably. -->
51+
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
4852
</manifest>

apps/mobile/capacitor.config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ const config: CapacitorConfig = {
77
server: {
88
androidScheme: "https",
99
},
10+
plugins: {
11+
LocalNotifications: {
12+
smallIcon: "ic_launcher",
13+
iconColor: "#10B981",
14+
sound: "default",
15+
},
16+
},
1017
};
1118

1219
export default config;

apps/mobile/ios/App/App/Info.plist

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,10 @@
6060
</array>
6161
<key>UIViewControllerBasedStatusBarAppearance</key>
6262
<true/>
63+
<key>UIBackgroundModes</key>
64+
<array>
65+
<string>fetch</string>
66+
<string>processing</string>
67+
</array>
6368
</dict>
6469
</plist>

apps/web/src/components/mobile/MobilePairingScreen.tsx

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,40 @@ export function MobilePairingScreen() {
3434
}
3535
};
3636

37+
const handlePasteFromClipboard = async () => {
38+
try {
39+
const text = await navigator.clipboard.readText();
40+
if (!text || text.trim().length === 0) {
41+
setErrorMessage("Clipboard is empty.");
42+
return;
43+
}
44+
setPairingInput(text.trim());
45+
setErrorMessage(null);
46+
47+
// Auto-submit if it looks like a valid pairing link.
48+
if (
49+
mobileBridge &&
50+
(text.trim().startsWith("okcode://") || text.trim().includes("?token="))
51+
) {
52+
setIsSubmitting(true);
53+
try {
54+
const nextState = await mobileBridge.applyPairingUrl(text.trim());
55+
if (!nextState.paired) {
56+
setErrorMessage(nextState.lastError ?? "Could not pair this device.");
57+
return;
58+
}
59+
window.location.reload();
60+
} catch (error) {
61+
setErrorMessage(error instanceof Error ? error.message : "Could not pair this device.");
62+
} finally {
63+
setIsSubmitting(false);
64+
}
65+
}
66+
} catch {
67+
setErrorMessage("Could not read clipboard. Paste the link manually instead.");
68+
}
69+
};
70+
3771
const handleReset = async () => {
3872
if (!mobileBridge) {
3973
return;
@@ -63,8 +97,8 @@ export function MobilePairingScreen() {
6397
</p>
6498
<h1 className="mt-3 text-2xl font-semibold tracking-tight">Pair this device</h1>
6599
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
66-
Paste a pairing link like <code>okcode://pair?server=…&amp;token=…</code> or a server URL
67-
that includes <code>?token=…</code>.
100+
Open <strong>Settings &rarr; Mobile Companion</strong> on your desktop to show a QR
101+
pairing code, then copy the link and paste it below.
68102
</p>
69103

70104
<div className="mt-5 space-y-3">
@@ -81,13 +115,25 @@ export function MobilePairingScreen() {
81115
</div>
82116

83117
<div className="mt-5 flex flex-wrap gap-2">
84-
<Button onClick={() => void handleSubmit()} disabled={isSubmitting}>
85-
{isSubmitting ? "Pairing..." : "Pair device"}
118+
<Button onClick={() => void handlePasteFromClipboard()} disabled={isSubmitting}>
119+
{isSubmitting ? "Pairing..." : "Paste from clipboard"}
120+
</Button>
121+
<Button
122+
variant="secondary"
123+
onClick={() => void handleSubmit()}
124+
disabled={isSubmitting || pairingInput.trim().length === 0}
125+
>
126+
Pair device
86127
</Button>
87128
<Button variant="outline" onClick={() => void handleReset()} disabled={isClearing}>
88129
Clear saved pairing
89130
</Button>
90131
</div>
132+
133+
<p className="mt-4 text-[11px] leading-relaxed text-muted-foreground/70">
134+
You can also open a pairing link directly from another app &mdash; it will be handled
135+
automatically via deep link.
136+
</p>
91137
</section>
92138
</div>
93139
);

apps/web/src/components/mobile/PairingQrCode.tsx

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ export function PairingQrCode() {
2323
const [loading, setLoading] = useState(false);
2424
const [svgHtml, setSvgHtml] = useState<string | null>(null);
2525
const [expiresIn, setExpiresIn] = useState<number | null>(null);
26+
const [copied, setCopied] = useState(false);
2627

2728
const fetchPairingLink = useCallback(async () => {
2829
setLoading(true);
2930
setError(null);
31+
setCopied(false);
3032
try {
3133
const origin = resolveServerHttpOrigin();
3234
const response = await fetch(`${origin}/api/pairing?ttl=300`);
@@ -80,6 +82,17 @@ export function PairingQrCode() {
8082
return () => clearInterval(interval);
8183
}, [pairing?.expiresAt, fetchPairingLink]);
8284

85+
const handleCopyLink = async () => {
86+
if (!pairing?.pairingUrl) return;
87+
try {
88+
await navigator.clipboard.writeText(pairing.pairingUrl);
89+
setCopied(true);
90+
setTimeout(() => setCopied(false), 2000);
91+
} catch {
92+
// Fallback: select the text in the details element
93+
}
94+
};
95+
8396
const formatTime = (seconds: number) => {
8497
const m = Math.floor(seconds / 60);
8598
const s = seconds % 60;
@@ -116,24 +129,23 @@ export function PairingQrCode() {
116129
{expiresIn > 0 ? <>Expires in {formatTime(expiresIn)}</> : <>Refreshing...</>}
117130
</p>
118131
)}
119-
<Button
120-
variant="ghost"
121-
size="sm"
122-
onClick={() => void fetchPairingLink()}
123-
disabled={loading}
124-
>
125-
{loading ? "Generating..." : "Generate new code"}
126-
</Button>
127-
{pairing?.pairingUrl && (
128-
<details className="w-full max-w-xs">
129-
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
130-
Show pairing link
131-
</summary>
132-
<code className="mt-1 block break-all rounded bg-muted px-2 py-1 text-[10px]">
133-
{pairing.pairingUrl}
134-
</code>
135-
</details>
136-
)}
132+
<div className="flex flex-wrap items-center justify-center gap-2">
133+
<Button variant="outline" size="sm" onClick={() => void handleCopyLink()}>
134+
{copied ? "Copied!" : "Copy pairing link"}
135+
</Button>
136+
<Button
137+
variant="ghost"
138+
size="sm"
139+
onClick={() => void fetchPairingLink()}
140+
disabled={loading}
141+
>
142+
{loading ? "Generating..." : "Refresh"}
143+
</Button>
144+
</div>
145+
<p className="max-w-xs text-center text-[11px] leading-relaxed text-muted-foreground/70">
146+
Scan the QR code with your phone camera, or copy the link and paste it in the mobile
147+
app.
148+
</p>
137149
</>
138150
) : loading ? (
139151
<div className="flex h-[220px] w-[220px] items-center justify-center rounded-xl border border-border bg-muted">

0 commit comments

Comments
 (0)