feat(mobile): QR/code pairing flow with security hardening (#317)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-14 10:44:54 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent dd6107d0d0
commit 4c37a57cfe
23 changed files with 1833 additions and 63 deletions
+1
View File
@@ -48,6 +48,7 @@
"jdenticon": "^3.3.0",
"livekit-client": "^2.18.1",
"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",
+12
View File
@@ -80,6 +80,9 @@ importers:
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.5)
qrcode.react:
specifier: ^4.2.0
version: 4.2.0(react@19.2.5)
react:
specifier: ^19.1.0
version: 19.2.5
@@ -1994,6 +1997,11 @@ packages:
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
qrcode.react@4.2.0:
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -4235,6 +4243,10 @@ snapshots:
property-information@7.1.0: {}
qrcode.react@4.2.0(react@19.2.5):
dependencies:
react: 19.2.5
queue-microtask@1.2.3: {}
react-diff-view@3.3.2(react@19.2.5):
@@ -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>
);
}
@@ -6,6 +6,7 @@ import {
MonitorCog,
Moon,
Search,
Smartphone,
Stethoscope,
Sun,
UserRound,
@@ -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";
@@ -28,6 +30,7 @@ export type SettingsSection =
| "notifications"
| "appearance"
| "tokens"
| "mobile"
| "doctor";
export const DEFAULT_SETTINGS_SECTION: SettingsSection = "profile";
@@ -72,6 +75,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Tokens",
icon: KeyRound,
},
{
value: "mobile",
label: "Mobile",
icon: Smartphone,
},
{
value: "doctor",
label: "Doctor",
@@ -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: {
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+42
View File
@@ -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
+100
View File
@@ -0,0 +1,100 @@
PODS:
- Flutter (1.0.0)
- flutter_secure_storage (6.0.0):
- Flutter
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleMLKit/BarcodeScanning (7.0.0):
- GoogleMLKit/MLKitCore
- MLKitBarcodeScanning (~> 6.0.0)
- GoogleMLKit/MLKitCore (7.0.0):
- MLKitCommon (~> 12.0.0)
- GoogleToolboxForMac/Defines (4.2.1)
- GoogleToolboxForMac/Logger (4.2.1):
- GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 4.2.1)
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMSessionFetcher/Core (3.5.0)
- MLImage (1.0.0-beta6)
- MLKitBarcodeScanning (6.0.0):
- MLKitCommon (~> 12.0)
- MLKitVision (~> 8.0)
- MLKitCommon (12.0.0):
- GoogleDataTransport (~> 10.0)
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GoogleUtilities/Logger (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLKitVision (8.0.0):
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLImage (= 1.0.0-beta6)
- MLKitCommon (~> 12.0)
- mobile_scanner (6.0.2):
- Flutter
- GoogleMLKit/BarcodeScanning (~> 7.0.0)
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- PromisesObjC (2.4.0)
DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
SPEC REPOS:
trunk:
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
- GoogleUtilities
- GTMSessionFetcher
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
- MLKitVision
- nanopb
- PromisesObjC
EXTERNAL SOURCES:
Flutter:
:path: Flutter
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/ios"
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PODFILE CHECKSUM: bd29822c3d5baf6b44b726f00ea3293a19339ef2
COCOAPODS: 1.16.2
+133 -3
View File
@@ -9,7 +9,9 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@@ -43,13 +45,20 @@
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
42F36AA401015EC51F984FEA /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
529B9B84AD8748153EB60BCF /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
57A155722F02B92C397E5AE2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
8EB101B7046447A0502FE6C9 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -57,13 +66,23 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
3C28C6B702C81085E6F96F2A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -78,6 +97,20 @@
path = RunnerTests;
sourceTree = "<group>";
};
746EA2D40EB8F35E67C48311 /* Pods */ = {
isa = PBXGroup;
children = (
57A155722F02B92C397E5AE2 /* Pods-Runner.debug.xcconfig */,
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */,
8EB101B7046447A0502FE6C9 /* Pods-Runner.profile.xcconfig */,
42F36AA401015EC51F984FEA /* Pods-RunnerTests.debug.xcconfig */,
7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */,
529B9B84AD8748153EB60BCF /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
@@ -96,6 +129,8 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
746EA2D40EB8F35E67C48311 /* Pods */,
D12A84F9A6072498822784CC /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -124,6 +159,15 @@
path = Runner;
sourceTree = "<group>";
};
D12A84F9A6072498822784CC /* Frameworks */ = {
isa = PBXGroup;
children = (
8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */,
CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -131,8 +175,10 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
29C475F38B9F5B6D27BD900B /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
3C28C6B702C81085E6F96F2A /* Frameworks */,
);
buildRules = (
);
@@ -148,12 +194,15 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
1D327B920BBA7EF4545E2A92 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */,
0CF701FE301CC7B2D25E86E6 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -225,6 +274,67 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
0CF701FE301CC7B2D25E86E6 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
1D327B920BBA7EF4545E2A92 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
29C475F38B9F5B6D27BD900B /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -256,6 +366,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -350,7 +477,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -383,6 +510,7 @@
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 42F36AA401015EC51F984FEA /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -400,6 +528,7 @@
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -415,6 +544,7 @@
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 529B9B84AD8748153EB60BCF /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -477,7 +607,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -528,7 +658,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
+3
View File
@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+66 -1
View File
@@ -1,7 +1,10 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'features/home/home_page.dart';
import 'features/pairing/pairing_page.dart';
import 'shared/auth/auth.dart';
import 'shared/theme/theme.dart';
class App extends HookConsumerWidget {
@@ -10,13 +13,75 @@ class App extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final themeMode = ref.watch(themeProvider);
final authState = ref.watch(authProvider);
return MaterialApp(
title: 'Sprout',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: themeMode,
home: const HomePage(),
home: authState.when(
loading: () => const _SplashScreen(),
error: (_, _) => const PairingPage(),
data: (state) => switch (state.status) {
AuthStatus.authenticated => const HomePage(),
AuthStatus.offline => const _OfflineScreen(),
_ => const PairingPage(),
},
),
);
}
}
class _SplashScreen extends StatelessWidget {
const _SplashScreen();
@override
Widget build(BuildContext context) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
}
class _OfflineScreen extends ConsumerWidget {
const _OfflineScreen();
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.wifiOff,
size: 48,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.xs),
Text(
'Unable to reach relay',
style: context.textTheme.titleMedium,
),
const SizedBox(height: Grid.xxs),
Text(
'Your pairing is saved — check your connection and try again.',
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.sm),
FilledButton.icon(
onPressed: () => ref.read(authProvider.notifier).retry(),
icon: const Icon(LucideIcons.refreshCw),
label: const Text('Retry'),
),
],
),
),
),
);
}
}
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import '../../shared/theme/theme.dart';
import 'pairing_provider.dart';
class PairingPage extends HookConsumerWidget {
const PairingPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pairingState = ref.watch(pairingProvider);
final codeController = useTextEditingController();
final isConnecting = pairingState.status == PairingStatus.connecting;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Branding
Icon(LucideIcons.sprout, size: 64, color: context.colors.primary),
const SizedBox(height: Grid.xs),
Text('Welcome to Sprout', style: context.textTheme.headlineSmall),
const SizedBox(height: Grid.xxs),
Text(
'Scan the QR code from your desktop app\nor paste a pairing code to connect.',
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.lg),
// Scan QR button
FilledButton.icon(
onPressed: isConnecting
? null
: () => _openScanner(context, ref),
icon: const Icon(LucideIcons.scanLine),
label: const Text('Scan QR Code'),
),
const SizedBox(height: Grid.sm),
// Divider
Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
),
child: Text(
'or paste pairing code',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: Grid.sm),
// Paste field
TextField(
controller: codeController,
decoration: const InputDecoration(
hintText: 'sprout://...',
prefixIcon: Icon(LucideIcons.link),
isDense: true,
),
autocorrect: false,
enableSuggestions: false,
enabled: !isConnecting,
contextMenuBuilder: (context, editableTextState) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
},
),
const SizedBox(height: Grid.twelve),
// Connect button
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: isConnecting
? null
: () {
final code = codeController.text.trim();
if (code.isNotEmpty) {
ref.read(pairingProvider.notifier).pair(code);
}
},
child: isConnecting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Connect'),
),
),
// Error message
if (pairingState.status == PairingStatus.error &&
pairingState.errorMessage != null) ...[
const SizedBox(height: Grid.twelve),
Container(
padding: const EdgeInsets.all(Grid.twelve),
decoration: BoxDecoration(
color: context.colors.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
LucideIcons.triangleAlert,
size: 16,
color: context.colors.onErrorContainer,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
pairingState.errorMessage!,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onErrorContainer,
),
),
),
],
),
),
],
const Spacer(flex: 3),
],
),
),
),
);
}
void _openScanner(BuildContext context, WidgetRef ref) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => _ScannerPage(
onScanned: (code) {
Navigator.of(context).pop();
ref.read(pairingProvider.notifier).pair(code);
},
),
),
);
}
}
class _ScannerPage extends StatefulWidget {
final void Function(String code) onScanned;
const _ScannerPage({required this.onScanned});
@override
State<_ScannerPage> createState() => _ScannerPageState();
}
class _ScannerPageState extends State<_ScannerPage> {
bool _handled = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scan QR Code'),
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Navigator.of(context).pop(),
),
),
body: MobileScanner(
onDetect: (capture) {
if (_handled) return;
final barcodes = capture.barcodes;
if (barcodes.isNotEmpty) {
final value = barcodes.first.rawValue;
if (value != null && value.isNotEmpty) {
_handled = true;
widget.onScanned(value);
}
}
},
),
);
}
}
@@ -0,0 +1,167 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import '../../shared/auth/auth.dart';
import '../../shared/relay/relay.dart';
/// HTTP client used by [PairingNotifier] for the validation request.
/// Override in tests to inject a mock client.
final pairingHttpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
enum PairingStatus { idle, connecting, success, error }
class PairingState {
final PairingStatus status;
final String? errorMessage;
const PairingState({this.status = PairingStatus.idle, this.errorMessage});
PairingState copyWith({PairingStatus? status, String? errorMessage}) =>
PairingState(
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
);
}
class PairingNotifier extends Notifier<PairingState> {
@override
PairingState build() => const PairingState();
Future<void> pair(String rawInput) async {
if (state.status == PairingStatus.connecting) return;
state = const PairingState(status: PairingStatus.connecting);
try {
final creds = _parseInput(rawInput);
// Test the connection before storing.
final client = RelayClient(
baseUrl: creds.relayUrl,
apiToken: creds.token,
httpClient: ref.read(pairingHttpClientProvider),
);
try {
await client.get('/api/users/me/profile');
} finally {
client.dispose();
}
await ref.read(authProvider.notifier).authenticate(creds);
state = const PairingState(status: PairingStatus.success);
} on FormatException catch (e) {
state = PairingState(
status: PairingStatus.error,
errorMessage: 'Invalid pairing code: ${e.message}',
);
} on RelayException catch (e) {
state = PairingState(
status: PairingStatus.error,
errorMessage:
'Could not connect to relay (${e.statusCode}). '
'Check that the pairing code is valid.',
);
} catch (e) {
state = PairingState(
status: PairingStatus.error,
errorMessage:
'Connection failed. Make sure your device can reach the '
'relay server.',
);
}
}
void reset() {
state = const PairingState();
}
StoredCredentials _parseInput(String raw) {
var payload = raw.trim();
if (payload.startsWith('sprout://')) {
payload = payload.substring('sprout://'.length);
}
// base64url decode.
final normalized = base64Url.normalize(payload);
final jsonStr = utf8.decode(base64Url.decode(normalized));
final decoded = jsonDecode(jsonStr);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Pairing payload is not a JSON object');
}
final relayUrl = decoded['relayUrl'] as String?;
final token = decoded['token'] as String?;
if (relayUrl == null || token == null) {
throw const FormatException('Missing relayUrl or token');
}
_validateRelayUrl(relayUrl);
return StoredCredentials(
relayUrl: relayUrl,
token: token,
pubkey: decoded['pubkey'] as String?,
);
}
/// Reject relay URLs that aren't HTTPS (unless debug mode) or target
/// private/link-local addresses.
void _validateRelayUrl(String url) {
final uri = Uri.parse(url);
if (!kDebugMode && uri.scheme != 'https') {
throw const FormatException('Relay URL must use HTTPS');
}
if (uri.scheme != 'http' && uri.scheme != 'https') {
throw FormatException('Invalid URL scheme: ${uri.scheme}');
}
final host = uri.host.toLowerCase();
if (host == 'localhost' || host == '127.0.0.1' || host == '::1') {
if (!kDebugMode) {
throw const FormatException('Relay URL cannot target localhost');
}
return;
}
// Block private and link-local IPs.
final ip = Uri.tryParse('http://$host')?.host ?? host;
if (_isPrivateHost(ip)) {
throw const FormatException(
'Relay URL cannot target private network addresses',
);
}
}
static bool _isPrivateHost(String host) {
final parts = host.split('.');
if (parts.length != 4) return false;
final octets = parts.map(int.tryParse).toList();
if (octets.any((o) => o == null)) return false;
final a = octets[0]!;
final b = octets[1]!;
// 10.0.0.0/8
if (a == 10) return true;
// 172.16.0.0/12
if (a == 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16
if (a == 192 && b == 168) return true;
// 169.254.0.0/16 (link-local / cloud metadata)
if (a == 169 && b == 254) return true;
return false;
}
}
final pairingProvider = NotifierProvider<PairingNotifier, PairingState>(
PairingNotifier.new,
);
+53 -53
View File
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/auth/auth.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
@@ -12,71 +12,42 @@ class SettingsPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final config = ref.watch(relayConfigProvider);
final urlController = useTextEditingController(text: config.baseUrl);
final tokenController = useTextEditingController(
text: config.apiToken ?? '',
);
final pubkeyController = useTextEditingController(
text: config.devPubkey ?? '',
);
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
padding: const EdgeInsets.all(Grid.xs),
children: [
Text('Relay Connection', style: context.textTheme.titleMedium),
// Connection info
Text('Connection', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.twelve),
TextField(
controller: urlController,
decoration: const InputDecoration(
labelText: 'Relay URL',
hintText: 'http://localhost:3000',
prefixIcon: Icon(LucideIcons.server),
ListTile(
leading: const Icon(LucideIcons.server),
title: const Text('Connected to'),
subtitle: Text(
config.baseUrl,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: context.colors.outlineVariant),
),
keyboardType: TextInputType.url,
autocorrect: false,
),
const SizedBox(height: Grid.twelve),
TextField(
controller: tokenController,
decoration: const InputDecoration(
labelText: 'API Token (optional)',
hintText: 'sprout_...',
prefixIcon: Icon(LucideIcons.key),
OutlinedButton.icon(
onPressed: () => _confirmSignOut(context, ref),
icon: const Icon(LucideIcons.logOut),
label: const Text('Sign Out'),
style: OutlinedButton.styleFrom(
foregroundColor: context.colors.error,
),
obscureText: true,
autocorrect: false,
),
const SizedBox(height: Grid.twelve),
TextField(
controller: pubkeyController,
decoration: InputDecoration(
labelText: 'Dev Pubkey (hex, for local relay)',
hintText: '3bf0c63...',
prefixIcon: const Icon(LucideIcons.userRound),
),
autocorrect: false,
),
const SizedBox(height: Grid.xs),
FilledButton(
onPressed: () {
final token = tokenController.text.trim();
final pubkey = pubkeyController.text.trim();
ref
.read(relayConfigProvider.notifier)
.update(
baseUrl: urlController.text.trim(),
apiToken: token.isEmpty ? null : token,
devPubkey: pubkey.isEmpty ? null : pubkey,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Relay config updated')),
);
},
child: const Text('Save'),
),
const SizedBox(height: Grid.sm),
// Appearance
Text('Appearance', style: context.textTheme.titleMedium),
const SizedBox(height: Grid.twelve),
SegmentedButton<ThemeMode>(
@@ -106,4 +77,33 @@ class SettingsPage extends HookConsumerWidget {
),
);
}
void _confirmSignOut(BuildContext context, WidgetRef ref) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Sign Out'),
content: const Text(
'You will need to scan a new pairing code from your '
'desktop app to reconnect.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
Navigator.of(ctx).pop();
ref.read(authProvider.notifier).signOut();
},
style: FilledButton.styleFrom(
backgroundColor: context.colors.error,
),
child: const Text('Sign Out'),
),
],
),
);
}
}
+2
View File
@@ -0,0 +1,2 @@
export 'auth_provider.dart';
export 'credential_storage.dart';
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../relay/relay.dart';
import 'credential_storage.dart';
enum AuthStatus { unknown, unauthenticated, authenticated, offline }
class AuthState {
final AuthStatus status;
final StoredCredentials? credentials;
const AuthState({required this.status, this.credentials});
}
class AuthNotifier extends AsyncNotifier<AuthState> {
@override
Future<AuthState> build() async {
// If a token is provided via dart-define in debug mode, treat as
// pre-authenticated.
if (kDebugMode && Env.apiToken.isNotEmpty) {
return const AuthState(status: AuthStatus.authenticated);
}
final storage = CredentialStorage();
final creds = await storage.load();
if (creds == null) {
return const AuthState(status: AuthStatus.unauthenticated);
}
// Validate stored credentials against the relay.
final client = RelayClient(baseUrl: creds.relayUrl, apiToken: creds.token);
try {
await client.get('/api/users/me/profile');
// Credentials are valid — propagate to relay config.
ref
.read(relayConfigProvider.notifier)
.update(
baseUrl: creds.relayUrl,
apiToken: creds.token,
devPubkey: null,
);
return AuthState(status: AuthStatus.authenticated, credentials: creds);
} on RelayException {
// Token is invalid or expired — clear and require re-pairing.
await storage.clear();
return const AuthState(status: AuthStatus.unauthenticated);
} catch (_) {
// Network error — keep credentials and let the user retry.
return AuthState(status: AuthStatus.offline, credentials: creds);
} finally {
client.dispose();
}
}
/// Retry credential validation (e.g. after a network error).
Future<void> retry() async {
ref.invalidateSelf();
await future;
}
Future<void> authenticate(StoredCredentials creds) async {
final storage = CredentialStorage();
await storage.save(creds);
ref
.read(relayConfigProvider.notifier)
.update(
baseUrl: creds.relayUrl,
apiToken: creds.token,
devPubkey: null,
);
state = AsyncData(
AuthState(status: AuthStatus.authenticated, credentials: creds),
);
}
Future<void> signOut() async {
final storage = CredentialStorage();
await storage.clear();
state = const AsyncData(AuthState(status: AuthStatus.unauthenticated));
}
}
final authProvider = AsyncNotifierProvider<AuthNotifier, AuthState>(
AuthNotifier.new,
);
@@ -0,0 +1,47 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class StoredCredentials {
final String relayUrl;
final String token;
final String? pubkey;
const StoredCredentials({
required this.relayUrl,
required this.token,
this.pubkey,
});
}
class CredentialStorage {
static const _keyRelayUrl = 'sprout_relay_url';
static const _keyToken = 'sprout_token';
static const _keyPubkey = 'sprout_pubkey';
final FlutterSecureStorage _storage;
CredentialStorage([FlutterSecureStorage? storage])
: _storage = storage ?? const FlutterSecureStorage();
Future<StoredCredentials?> load() async {
final relayUrl = await _storage.read(key: _keyRelayUrl);
final token = await _storage.read(key: _keyToken);
if (relayUrl == null || token == null) return null;
final pubkey = await _storage.read(key: _keyPubkey);
return StoredCredentials(relayUrl: relayUrl, token: token, pubkey: pubkey);
}
Future<void> save(StoredCredentials credentials) async {
await _storage.write(key: _keyRelayUrl, value: credentials.relayUrl);
await _storage.write(key: _keyToken, value: credentials.token);
if (credentials.pubkey != null) {
await _storage.write(key: _keyPubkey, value: credentials.pubkey);
}
}
Future<void> clear() async {
await _storage.delete(key: _keyRelayUrl);
await _storage.delete(key: _keyToken);
await _storage.delete(key: _keyPubkey);
}
}
+1 -1
View File
@@ -73,5 +73,5 @@ class RelayException implements Exception {
RelayException(this.statusCode, this.body);
@override
String toString() => 'RelayException($statusCode): $body';
String toString() => 'RelayException($statusCode)';
}
+206 -1
View File
@@ -121,6 +121,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection:
dependency: transitive
description:
@@ -193,6 +201,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
@@ -238,11 +254,64 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
url: "https://pub.dev"
source: hosted
version: "9.2.4"
flutter_secure_storage_linux:
dependency: transitive
description:
name: flutter_secure_storage_linux
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
url: "https://pub.dev"
source: hosted
version: "1.2.3"
flutter_secure_storage_macos:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
url: "https://pub.dev"
source: hosted
version: "3.1.3"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
name: flutter_secure_storage_platform_interface
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
url: "https://pub.dev"
source: hosted
version: "1.1.2"
flutter_secure_storage_web:
dependency: transitive
description:
name: flutter_secure_storage_web
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
url: "https://pub.dev"
source: hosted
version: "1.2.1"
flutter_secure_storage_windows:
dependency: transitive
description:
name: flutter_secure_storage_windows
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
url: "https://pub.dev"
source: hosted
version: "3.1.2"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
freezed_annotation:
dependency: transitive
description:
@@ -267,6 +336,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
hooks_riverpod:
dependency: "direct main"
description:
@@ -307,6 +384,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.dev"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
js:
dependency: transitive
description:
name: js
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
url: "https://pub.dev"
source: hosted
version: "0.6.7"
json_annotation:
dependency: transitive
description:
@@ -395,6 +496,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mobile_scanner:
dependency: "direct main"
description:
name: mobile_scanner
sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173"
url: "https://pub.dev"
source: hosted
version: "6.0.11"
mocktail:
dependency: "direct dev"
description:
@@ -403,6 +512,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
node_preamble:
dependency: transitive
description:
@@ -411,6 +528,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.2"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
package_config:
dependency: transitive
description:
@@ -427,6 +552,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.dev"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
@@ -688,6 +877,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.1"
win32:
dependency: transitive
description:
name: win32
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
url: "https://pub.dev"
source: hosted
version: "5.15.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
@@ -706,4 +911,4 @@ packages:
version: "2.2.4"
sdks:
dart: ">=3.11.4 <4.0.0"
flutter: ">=3.32.0"
flutter: ">=3.38.4"
+2
View File
@@ -13,6 +13,8 @@ dependencies:
flutter_hooks: ^0.21.3
lucide_icons_flutter: ^3.1.0
http: ^1.4.0
flutter_secure_storage: ^9.2.4
mobile_scanner: ^6.0.2
dev_dependencies:
flutter_test:
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sprout_mobile/features/pairing/pairing_page.dart';
import 'package:sprout_mobile/features/pairing/pairing_provider.dart';
import '../../helpers/widget_helpers.dart';
void main() {
group('PairingPage', () {
testWidgets('renders branding, scan button, divider, and text field', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
expect(find.text('Welcome to Sprout'), findsOneWidget);
expect(find.text('Scan QR Code'), findsOneWidget);
expect(find.text('or paste pairing code'), findsOneWidget);
expect(find.text('Connect'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
});
testWidgets('connect button is below text field, not beside it', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
final textField = tester.getBottomLeft(find.byType(TextField));
final connectButton = tester.getTopLeft(
find.widgetWithText(FilledButton, 'Connect'),
);
// The connect button should be below the text field.
expect(connectButton.dy, greaterThan(textField.dy));
});
testWidgets('connect button is full width', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
final connectButton = tester.getSize(
find.widgetWithText(FilledButton, 'Connect'),
);
final textField = tester.getSize(find.byType(TextField));
// Button width should be close to the text field width (both full-width).
expect(connectButton.width, closeTo(textField.width, 2.0));
});
testWidgets('shows error container when pairing fails', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
pairingProvider.overrideWith(
() => _ErrorPairingNotifier('Invalid pairing code: bad input'),
),
],
child: const PairingPage(),
),
);
await tester.pump();
expect(find.text('Invalid pairing code: bad input'), findsOneWidget);
});
testWidgets('shows spinner when connecting', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
pairingProvider.overrideWith(() => _ConnectingPairingNotifier()),
],
child: const PairingPage(),
),
);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Connect text should be replaced by spinner.
expect(find.text('Connect'), findsNothing);
});
testWidgets('text field and buttons disabled when connecting', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
pairingProvider.overrideWith(() => _ConnectingPairingNotifier()),
],
child: const PairingPage(),
),
);
await tester.pump();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.enabled, isFalse);
});
});
}
class _ErrorPairingNotifier extends Notifier<PairingState>
implements PairingNotifier {
final String error;
_ErrorPairingNotifier(this.error);
@override
PairingState build() =>
PairingState(status: PairingStatus.error, errorMessage: error);
@override
Future<void> pair(String rawInput) async {}
@override
void reset() {}
}
class _ConnectingPairingNotifier extends Notifier<PairingState>
implements PairingNotifier {
@override
PairingState build() => const PairingState(status: PairingStatus.connecting);
@override
Future<void> pair(String rawInput) async {}
@override
void reset() {}
}
@@ -0,0 +1,299 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
import 'package:sprout_mobile/features/pairing/pairing_provider.dart';
import 'package:sprout_mobile/shared/auth/auth.dart';
/// Encode a credentials payload the same way the desktop app would.
String _encodePairingCode({
String relayUrl = 'http://test:3000',
String token = 'sprout_test_token',
String? pubkey,
}) {
final json = <String, dynamic>{
'relayUrl': relayUrl,
'token': token,
// ignore: use_null_aware_elements
if (pubkey != null) 'pubkey': pubkey,
};
return base64Url.encode(utf8.encode(jsonEncode(json)));
}
/// A fake [AuthNotifier] that records calls instead of touching secure storage.
class FakeAuthNotifier extends AsyncNotifier<AuthState>
implements AuthNotifier {
StoredCredentials? lastCredentials;
bool signedOut = false;
@override
Future<AuthState> build() async =>
const AuthState(status: AuthStatus.unauthenticated);
@override
Future<void> authenticate(StoredCredentials creds) async {
lastCredentials = creds;
state = AsyncData(
AuthState(status: AuthStatus.authenticated, credentials: creds),
);
}
@override
Future<void> signOut() async {
signedOut = true;
state = const AsyncData(AuthState(status: AuthStatus.unauthenticated));
}
@override
Future<void> retry() async {}
}
void main() {
group('PairingNotifier', () {
late ProviderContainer container;
late FakeAuthNotifier fakeAuth;
/// Creates a container with the HTTP client wired to [mockClient].
ProviderContainer createContainer(http_testing.MockClient mockClient) {
fakeAuth = FakeAuthNotifier();
return ProviderContainer(
overrides: [
authProvider.overrideWith(() => fakeAuth),
pairingHttpClientProvider.overrideWithValue(mockClient),
],
);
}
tearDown(() => container.dispose());
test('starts in idle state', () {
final mock = http_testing.MockClient(
(_) async => http.Response('{}', 200),
);
container = createContainer(mock);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.idle);
expect(state.errorMessage, isNull);
});
test('successful pairing with raw base64 code', () async {
final mock = http_testing.MockClient((request) async {
expect(request.url.path, '/api/users/me/profile');
return http.Response(jsonEncode({'id': '1', 'name': 'Wes'}), 200);
});
container = createContainer(mock);
final code = _encodePairingCode();
await container.read(pairingProvider.notifier).pair(code);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.success);
expect(fakeAuth.lastCredentials?.relayUrl, 'http://test:3000');
expect(fakeAuth.lastCredentials?.token, 'sprout_test_token');
});
test('successful pairing with sprout:// prefix', () async {
final mock = http_testing.MockClient(
(_) async => http.Response(jsonEncode({'id': '1'}), 200),
);
container = createContainer(mock);
final code = 'sprout://${_encodePairingCode()}';
await container.read(pairingProvider.notifier).pair(code);
expect(container.read(pairingProvider).status, PairingStatus.success);
});
test('successful pairing with pubkey', () async {
final mock = http_testing.MockClient(
(_) async => http.Response(jsonEncode({'id': '1'}), 200),
);
container = createContainer(mock);
final code = _encodePairingCode(pubkey: 'abc123');
await container.read(pairingProvider.notifier).pair(code);
expect(container.read(pairingProvider).status, PairingStatus.success);
expect(fakeAuth.lastCredentials?.pubkey, 'abc123');
});
test('relay 401 sets error state', () async {
final mock = http_testing.MockClient(
(_) async => http.Response('{"error": "unauthorized"}', 401),
);
container = createContainer(mock);
final code = _encodePairingCode();
await container.read(pairingProvider.notifier).pair(code);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.error);
expect(state.errorMessage, contains('Could not connect to relay'));
expect(state.errorMessage, contains('401'));
});
test('relay 500 sets error state', () async {
final mock = http_testing.MockClient(
(_) async => http.Response('internal error', 500),
);
container = createContainer(mock);
final code = _encodePairingCode();
await container.read(pairingProvider.notifier).pair(code);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.error);
expect(state.errorMessage, contains('500'));
});
test('network error sets generic error', () async {
final mock = http_testing.MockClient(
(_) => throw Exception('no internet'),
);
container = createContainer(mock);
final code = _encodePairingCode();
await container.read(pairingProvider.notifier).pair(code);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.error);
expect(state.errorMessage, contains('Connection failed'));
});
test('invalid base64 sets format error', () async {
final mock = http_testing.MockClient(
(_) async => http.Response('{}', 200),
);
container = createContainer(mock);
await container.read(pairingProvider.notifier).pair('not-valid!!!');
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.error);
expect(state.errorMessage, contains('Invalid pairing code'));
});
test('base64 with valid JSON but missing fields errors', () async {
final mock = http_testing.MockClient(
(_) async => http.Response('{}', 200),
);
container = createContainer(mock);
// Valid base64 JSON, but no relayUrl/token keys.
final code = base64Url.encode(utf8.encode(jsonEncode({'foo': 'bar'})));
await container.read(pairingProvider.notifier).pair(code);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.error);
expect(state.errorMessage, contains('Missing relayUrl or token'));
});
test('empty input errors', () async {
final mock = http_testing.MockClient(
(_) async => http.Response('{}', 200),
);
container = createContainer(mock);
await container.read(pairingProvider.notifier).pair('');
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.error);
});
test('whitespace-padded input is trimmed', () async {
final mock = http_testing.MockClient(
(_) async => http.Response(jsonEncode({'id': '1'}), 200),
);
container = createContainer(mock);
final code = ' ${_encodePairingCode()} \n';
await container.read(pairingProvider.notifier).pair(code);
expect(container.read(pairingProvider).status, PairingStatus.success);
});
test('rejects private IP relay URLs (SSRF)', () async {
final mock = http_testing.MockClient(
(_) async => http.Response('{}', 200),
);
container = createContainer(mock);
for (final ip in [
'10.0.0.1',
'172.16.0.1',
'192.168.1.1',
'169.254.169.254',
]) {
final code = _encodePairingCode(relayUrl: 'http://$ip:3000');
await container.read(pairingProvider.notifier).pair(code);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.error, reason: 'should reject $ip');
expect(state.errorMessage, contains('private network'));
container.read(pairingProvider.notifier).reset();
}
});
test('rejects non-http/https schemes', () async {
final mock = http_testing.MockClient(
(_) async => http.Response('{}', 200),
);
container = createContainer(mock);
final code = _encodePairingCode(relayUrl: 'file:///etc/passwd');
await container.read(pairingProvider.notifier).pair(code);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.error);
expect(state.errorMessage, contains('Invalid pairing code'));
});
test('rejects JSON array payload', () async {
final mock = http_testing.MockClient(
(_) async => http.Response('{}', 200),
);
container = createContainer(mock);
final code = base64Url.encode(utf8.encode(jsonEncode([1, 2, 3])));
await container.read(pairingProvider.notifier).pair(code);
final state = container.read(pairingProvider);
expect(state.status, PairingStatus.error);
expect(state.errorMessage, contains('not a JSON object'));
});
test('ignores duplicate pair() calls while connecting', () async {
var requestCount = 0;
final mock = http_testing.MockClient((_) async {
requestCount++;
// Simulate slow network.
await Future<void>.delayed(const Duration(milliseconds: 50));
return http.Response(jsonEncode({'id': '1'}), 200);
});
container = createContainer(mock);
final code = _encodePairingCode();
// Fire two calls without awaiting the first.
final f1 = container.read(pairingProvider.notifier).pair(code);
final f2 = container.read(pairingProvider.notifier).pair(code);
await Future.wait([f1, f2]);
// Only one network request should have been made.
expect(requestCount, 1);
});
test('reset returns to idle', () async {
final mock = http_testing.MockClient(
(_) async => http.Response(jsonEncode({'id': '1'}), 200),
);
container = createContainer(mock);
await container.read(pairingProvider.notifier).pair(_encodePairingCode());
expect(container.read(pairingProvider).status, PairingStatus.success);
container.read(pairingProvider.notifier).reset();
expect(container.read(pairingProvider).status, PairingStatus.idle);
});
});
}
+20 -4
View File
@@ -1,10 +1,26 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sprout_mobile/app.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sprout_mobile/app.dart';
import 'package:sprout_mobile/shared/auth/auth.dart';
void main() {
testWidgets('App renders without crashing', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: App()));
expect(find.text('Channels'), findsWidgets);
testWidgets('App renders pairing page when unauthenticated', (
WidgetTester tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [authProvider.overrideWith(() => _FakeAuthNotifier())],
child: const App(),
),
);
await tester.pump();
expect(find.text('Welcome to Sprout'), findsOneWidget);
});
}
class _FakeAuthNotifier extends AuthNotifier {
@override
Future<AuthState> build() async {
return const AuthState(status: AuthStatus.unauthenticated);
}
}