Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

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

## [0.0.13] - 2026-04-01

See [docs/releases/v0.0.13.md](docs/releases/v0.0.13.md) for full notes.

### Added

- Push notifications for approval requests, user-input requests, turn completions, and session errors on mobile.
- QR code pairing flow: desktop shows scannable QR, mobile supports clipboard paste and auto-pair.
- Token rotation and revocation model with short-lived pairing tokens.
- Connection state banner for mobile companion (connecting, reconnecting, disconnected).
- Android `POST_NOTIFICATIONS` and `SCHEDULE_EXACT_ALARM` permissions.
- iOS `UIBackgroundModes` for background processing.
- Capacitor `LocalNotifications` plugin configuration.
- `GET /api/pairing` HTTP endpoint for short-lived pairing link generation.
- WebSocket methods: `server.generatePairingLink`, `server.rotateToken`, `server.revokeToken`, `server.listTokens`.

## [0.0.12] - 2026-04-01

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.
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,8 @@
<!-- Permissions -->

<uses-permission android:name="android.permission.INTERNET" />
<!-- Required on Android 13+ (API 33) to display local notifications. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Keep a minimal wake-lock so scheduled notifications fire reliably. -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
</manifest>
7 changes: 7 additions & 0 deletions apps/mobile/capacitor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ const config: CapacitorConfig = {
server: {
androidScheme: "https",
},
plugins: {
LocalNotifications: {
smallIcon: "ic_launcher",
iconColor: "#10B981",
sound: "default",
},
},
};

export default config;
5 changes: 5 additions & 0 deletions apps/mobile/ios/App/App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,10 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
</dict>
</plist>
54 changes: 50 additions & 4 deletions apps/web/src/components/mobile/MobilePairingScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,40 @@ export function MobilePairingScreen() {
}
};

const handlePasteFromClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
if (!text || text.trim().length === 0) {
setErrorMessage("Clipboard is empty.");
return;
}
setPairingInput(text.trim());
setErrorMessage(null);

// Auto-submit if it looks like a valid pairing link.
if (
mobileBridge &&
(text.trim().startsWith("okcode://") || text.trim().includes("?token="))
) {
setIsSubmitting(true);
try {
const nextState = await mobileBridge.applyPairingUrl(text.trim());
if (!nextState.paired) {
setErrorMessage(nextState.lastError ?? "Could not pair this device.");
return;
}
window.location.reload();
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : "Could not pair this device.");
} finally {
setIsSubmitting(false);
}
}
} catch {
setErrorMessage("Could not read clipboard. Paste the link manually instead.");
}
};

const handleReset = async () => {
if (!mobileBridge) {
return;
Expand Down Expand Up @@ -63,8 +97,8 @@ export function MobilePairingScreen() {
</p>
<h1 className="mt-3 text-2xl font-semibold tracking-tight">Pair this device</h1>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Paste a pairing link like <code>okcode://pair?server=…&amp;token=…</code> or a server URL
that includes <code>?token=…</code>.
Open <strong>Settings &rarr; Mobile Companion</strong> on your desktop to show a QR
pairing code, then copy the link and paste it below.
</p>

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

<div className="mt-5 flex flex-wrap gap-2">
<Button onClick={() => void handleSubmit()} disabled={isSubmitting}>
{isSubmitting ? "Pairing..." : "Pair device"}
<Button onClick={() => void handlePasteFromClipboard()} disabled={isSubmitting}>
{isSubmitting ? "Pairing..." : "Paste from clipboard"}
</Button>
<Button
variant="secondary"
onClick={() => void handleSubmit()}
disabled={isSubmitting || pairingInput.trim().length === 0}
>
Pair device
</Button>
<Button variant="outline" onClick={() => void handleReset()} disabled={isClearing}>
Clear saved pairing
</Button>
</div>

<p className="mt-4 text-[11px] leading-relaxed text-muted-foreground/70">
You can also open a pairing link directly from another app &mdash; it will be handled
automatically via deep link.
</p>
</section>
</div>
);
Expand Down
48 changes: 30 additions & 18 deletions apps/web/src/components/mobile/PairingQrCode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ export function PairingQrCode() {
const [loading, setLoading] = useState(false);
const [svgHtml, setSvgHtml] = useState<string | null>(null);
const [expiresIn, setExpiresIn] = useState<number | null>(null);
const [copied, setCopied] = useState(false);

const fetchPairingLink = useCallback(async () => {
setLoading(true);
setError(null);
setCopied(false);
try {
const origin = resolveServerHttpOrigin();
const response = await fetch(`${origin}/api/pairing?ttl=300`);
Expand Down Expand Up @@ -80,6 +82,17 @@ export function PairingQrCode() {
return () => clearInterval(interval);
}, [pairing?.expiresAt, fetchPairingLink]);

const handleCopyLink = async () => {
if (!pairing?.pairingUrl) return;
try {
await navigator.clipboard.writeText(pairing.pairingUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: select the text in the details element
}
};

const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
Expand Down Expand Up @@ -116,24 +129,23 @@ export function PairingQrCode() {
{expiresIn > 0 ? <>Expires in {formatTime(expiresIn)}</> : <>Refreshing...</>}
</p>
)}
<Button
variant="ghost"
size="sm"
onClick={() => void fetchPairingLink()}
disabled={loading}
>
{loading ? "Generating..." : "Generate new code"}
</Button>
{pairing?.pairingUrl && (
<details className="w-full max-w-xs">
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
Show pairing link
</summary>
<code className="mt-1 block break-all rounded bg-muted px-2 py-1 text-[10px]">
{pairing.pairingUrl}
</code>
</details>
)}
<div className="flex flex-wrap items-center justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => void handleCopyLink()}>
{copied ? "Copied!" : "Copy pairing link"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void fetchPairingLink()}
disabled={loading}
>
{loading ? "Generating..." : "Refresh"}
</Button>
</div>
<p className="max-w-xs text-center text-[11px] leading-relaxed text-muted-foreground/70">
Scan the QR code with your phone camera, or copy the link and paste it in the mobile
app.
</p>
</>
) : loading ? (
<div className="flex h-[220px] w-[220px] items-center justify-center rounded-xl border border-border bg-muted">
Expand Down
Loading
Loading