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
1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"emoji-mart": "^5.6.0",
"jdenticon": "^3.3.0",
"lucide-react": "^0.577.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.0",
"react-diff-view": "^3.3.2",
"react-dom": "^19.1.0",
Expand Down
12 changes: 12 additions & 0 deletions desktop/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

237 changes: 237 additions & 0 deletions desktop/src/features/settings/ui/MobilePairingCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { QRCodeSVG } from "qrcode.react";
import { Check, Copy, Loader2, Smartphone, TriangleAlert } from "lucide-react";

import { useMintTokenMutation } from "@/features/tokens/hooks";
import { getRelayHttpUrl } from "@/shared/api/tauri";
import type { TokenScope } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";

const MOBILE_SCOPES: TokenScope[] = [
"messages:read",
"messages:write",
"channels:read",
"users:read",
"files:read",
];
const EXPIRES_IN_DAYS = 90;

function toBase64Url(str: string): string {
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

type PairingPayload = {
relayUrl: string;
token: string;
pubkey: string;
};

function PairingDialog({
open,
onOpenChange,
currentPubkey,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
currentPubkey: string;
}) {
const mintMutation = useMintTokenMutation();
const mintRef = useRef(mintMutation.mutateAsync);
mintRef.current = mintMutation.mutateAsync;

const [pairingUri, setPairingUri] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);

const generate = useCallback(async (pubkey: string) => {
const [tokenResult, relayUrl] = await Promise.all([
mintRef.current({
name: `mobile-${Date.now()}`,
scopes: [...MOBILE_SCOPES],
expiresInDays: EXPIRES_IN_DAYS,
}),
getRelayHttpUrl(),
]);

const payload: PairingPayload = {
relayUrl,
token: tokenResult.token,
pubkey,
};
return `sprout://${toBase64Url(JSON.stringify(payload))}`;
}, []);

useEffect(() => {
if (!open) return;

setPairingUri(null);
setError(null);
setCopied(false);

let cancelled = false;

generate(currentPubkey).then(
(uri) => {
if (!cancelled) setPairingUri(uri);
},
(err) => {
if (!cancelled) {
setError(
err instanceof Error
? err.message
: "Failed to generate pairing code",
);
}
},
);

return () => {
cancelled = true;
};
}, [open, currentPubkey, generate]);

async function handleCopy() {
if (!pairingUri) return;
await navigator.clipboard.writeText(pairingUri);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}

return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent
className="max-w-md overflow-hidden p-0"
data-testid="mobile-pairing-dialog"
>
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Pair Mobile Device</DialogTitle>
<DialogDescription>
Scan this QR code with the Sprout mobile app, or copy the pairing
code for manual setup.
</DialogDescription>
</DialogHeader>

<div className="flex-1 overflow-y-auto px-6 py-4">
{error ? (
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
) : !pairingUri ? (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Generating pairing code…
</p>
</div>
) : (
<div className="space-y-4">
<div className="flex justify-center rounded-lg border border-border/70 bg-white p-4">
<QRCodeSVG
data-testid="mobile-pairing-qr"
level="M"
size={240}
value={pairingUri}
/>
</div>

<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Pairing code
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded-lg border border-border bg-muted/50 px-3 py-2 text-xs">
{pairingUri}
</code>
<Button
data-testid="copy-pairing-code"
onClick={handleCopy}
size="sm"
variant="outline"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>

<p className="text-xs text-muted-foreground">
This token expires in {EXPIRES_IN_DAYS} days. You can revoke
it from the Tokens settings at any time.
</p>
</div>
)}
</div>

<div className="flex justify-end border-t border-border/60 bg-background/95 px-6 py-4">
<Button
data-testid="mobile-pairing-done"
onClick={() => onOpenChange(false)}
size="sm"
variant="outline"
>
Done
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

export function MobilePairingCard({
currentPubkey,
}: {
currentPubkey?: string;
}) {
const [dialogOpen, setDialogOpen] = useState(false);

return (
<section className="min-w-0 space-y-3" data-testid="settings-mobile">
<div className="space-y-1">
<h2 className="text-sm font-semibold tracking-tight">Mobile</h2>
<p className="text-sm text-muted-foreground">
Connect the Sprout mobile app to this relay by scanning a QR code or
pasting a pairing code.
</p>
</div>

<div className="flex items-center gap-3 rounded-xl border border-border/80 bg-muted/25 px-4 py-3">
<Smartphone className="h-5 w-5 text-muted-foreground" />
<div className="flex-1">
<p className="text-sm font-medium">Pair Mobile Device</p>
<p className="text-xs text-muted-foreground">
Generate a one-time pairing code for the mobile app
</p>
</div>
<Button
data-testid="pair-mobile-button"
disabled={!currentPubkey}
onClick={() => setDialogOpen(true)}
size="sm"
>
Pair
</Button>
</div>

{currentPubkey && (
<PairingDialog
currentPubkey={currentPubkey}
onOpenChange={setDialogOpen}
open={dialogOpen}
/>
)}
</section>
);
}
10 changes: 10 additions & 0 deletions desktop/src/features/settings/ui/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
MonitorCog,
Moon,
Search,
Smartphone,
Stethoscope,
Sun,
UserRound,
Expand All @@ -20,6 +21,7 @@ import { cn } from "@/shared/lib/cn";
import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider";
import { SYNTAX_THEMES, isLightTheme } from "@/shared/theme/theme-loader";
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
import { MobilePairingCard } from "./MobilePairingCard";
import { NotificationSettingsCard } from "./NotificationSettingsCard";
import { ProfileSettingsCard } from "./ProfileSettingsCard";

Expand All @@ -28,6 +30,7 @@ export type SettingsSection =
| "notifications"
| "appearance"
| "tokens"
| "mobile"
| "doctor";

export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
Expand Down Expand Up @@ -72,6 +75,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Tokens",
icon: KeyRound,
},
{
value: "mobile",
label: "Mobile",
icon: Smartphone,
},
{
value: "doctor",
label: "Doctor",
Expand Down Expand Up @@ -228,6 +236,8 @@ export function renderSettingsSection(
return <ThemeSettingsCard />;
case "tokens":
return <TokenSettingsCard currentPubkey={props.currentPubkey} />;
case "mobile":
return <MobilePairingCard currentPubkey={props.currentPubkey} />;
case "doctor":
return <DoctorSettingsPanel />;
default: {
Expand Down
1 change: 1 addition & 0 deletions mobile/ios/Flutter/Debug.xcconfig
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
1 change: 1 addition & 0 deletions mobile/ios/Flutter/Release.xcconfig
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
42 changes: 42 additions & 0 deletions mobile/ios/Podfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
platform :ios, '16.0'

# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}

def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end

File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end

require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)

flutter_ios_podfile_setup

target 'Runner' do
use_frameworks!

flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end

post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
Loading