From 6518ce21ec8bfbb1d868df1d3f9c58486f1d04f4 Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Thu, 27 Aug 2026 09:55:33 -0400 Subject: [PATCH 1/5] feat(blox-pairing): web hand-off links + fragment return template (native) Adds lib/core/services/blox_pairing_links.dart (dart:io-free): the buildBloxWebPairUrl / buildBloxNativePairUrl builders, the v1 return template in the https FRAGMENT form (the bearer secret never reaches a server), and parseAutopinCompleteParams(Uri) which reads the fragment first, then the query, plus AutopinCompleteParams validation (non-empty secret, per-field length caps, no control chars). The four $placeholders stay literal (raw strings). Unit-tested. Sender (blox_pairing_screen.dart): builds the blox.fx.land URL with the SAME params; on kIsWeb uses it directly; on native tries fxblox:// first and, when launchUrl returns false or throws, offers "Pair in browser" instead of the old "app not installed" dead end. Desktop's manual pairing dialog gains a "Pair in browser" button. Receiver (deep_link_service.dart): /autopin-complete arm in the universal-link handler reusing _handleAutoPinComplete, which now uses the shared parser + validation. Android app-link for /autopin-complete and the AASA path so the OS opens FxFiles directly (both preserve the fragment). Contract: docs/AUTOPIN-HANDOFF.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QyQw3WtVXDTtvJKN7ykczw --- android/app/src/main/AndroidManifest.xml | 6 +- lib/core/services/blox_pairing_links.dart | 269 +++++++++++++++ lib/core/services/deep_link_service.dart | 38 ++- .../settings/screens/blox_pairing_screen.dart | 140 +++++++- site/.well-known/apple-app-site-association | 2 +- .../services/blox_pairing_links_test.dart | 316 ++++++++++++++++++ 6 files changed, 744 insertions(+), 27 deletions(-) create mode 100644 lib/core/services/blox_pairing_links.dart create mode 100644 test/unit/core/services/blox_pairing_links_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d815c12..47602b2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -71,12 +71,16 @@ - + + diff --git a/lib/core/services/blox_pairing_links.dart b/lib/core/services/blox_pairing_links.dart new file mode 100644 index 0000000..567e282 --- /dev/null +++ b/lib/core/services/blox_pairing_links.dart @@ -0,0 +1,269 @@ +/// Blox auto-pin pairing hand-off links — the FxFiles side of the contract in +/// `docs/AUTOPIN-HANDOFF.md` (v1). +/// +/// This file is deliberately free of `dart:io`, `package:web` and Flutter +/// imports so it compiles in EVERY graph (native, desktop, the web shell) and +/// is unit-testable on the Dart VM. +/// +/// Outbound (FxFiles → FxBlox): +/// native `fxblox://autopin-pair?token=&endpoint=&returnUrl=` +/// web `https://blox.fx.land/autopin-pair?token=&endpoint=&returnUrl=` +/// `returnUrl` is a URL-encoded TEMPLATE carrying the literal placeholders +/// `$secret`, `$hardwareId`, `$bloxPeerId`, `$bloxName`; FxBlox substitutes +/// them (each value `encodeURIComponent`-ed) and navigates to the result. +/// +/// Return (FxBlox → FxFiles): the fragment form is canonical so the bearer +/// secret never reaches a server or a log: +/// `https://files.fx.land/autopin-complete#secret=…&hardwareId=…&bloxPeerId=…&bloxName=…` +/// The legacy `fxfiles://autopin-complete?secret=…` (query) form and the web +/// app's hash-route form `https://files.fx.land/app/#/autopin-complete?secret=…` +/// are still accepted by [parseAutopinCompleteParams]. +library; + +/// Web FxBlox pairing entry point. +const String kBloxWebPairBase = 'https://blox.fx.land/autopin-pair'; + +/// Native FxBlox app pairing deep link. +const String kBloxNativePairBase = 'fxblox://autopin-pair'; + +/// Host + path of the return forwarder (`site/autopin-complete/index.html`) +/// and of the native universal-link arm. +const String kAutopinReturnHost = 'files.fx.land'; +const String kAutopinReturnPath = '/autopin-complete'; + +/// Default pinning/IPFS API base sent as `endpoint` when the user has not +/// overridden `SecureStorageKeys.ipfsServerUrl`. +const String kDefaultPinningEndpoint = 'https://api.cloud.fx.land'; + +/// The four placeholders FxBlox substitutes in the return template. Raw +/// strings: the `$` is LITERAL, never Dart interpolation. +const List kAutopinReturnPlaceholders = [ + r'$secret', + r'$hardwareId', + r'$bloxPeerId', + r'$bloxName', +]; + +/// Canonical (v1) return template — fragment form. Sent URL-encoded as +/// `returnUrl`; FxBlox decodes it, substitutes the placeholders and opens it. +const String kAutopinReturnTemplate = + r'https://files.fx.land/autopin-complete#secret=$secret&hardwareId=$hardwareId&bloxPeerId=$bloxPeerId&bloxName=$bloxName'; + +/// The pre-v1 custom-scheme template. No longer SENT, but documented here (and +/// still parsed by [parseAutopinCompleteParams]) because older FxBlox builds +/// and the static forwarder still produce the query form. +const String kAutopinLegacyReturnTemplate = + r'fxfiles://autopin-complete?secret=$secret&hardwareId=$hardwareId&bloxPeerId=$bloxPeerId&bloxName=$bloxName'; + +/// True iff [template] carries every placeholder in [kAutopinReturnPlaceholders]. +bool returnTemplateHasAllPlaceholders(String template) => + kAutopinReturnPlaceholders.every(template.contains); + +String _pairQuery({ + required String token, + required String endpoint, + required String returnTemplate, +}) { + if (token.isEmpty) { + throw ArgumentError.value(token, 'token', 'must not be empty'); + } + if (endpoint.isEmpty) { + throw ArgumentError.value(endpoint, 'endpoint', 'must not be empty'); + } + if (!returnTemplateHasAllPlaceholders(returnTemplate)) { + throw ArgumentError.value( + returnTemplate, + 'returnTemplate', + 'must contain all of $kAutopinReturnPlaceholders', + ); + } + // Uri.encodeComponent percent-encodes `$ & = # : /` so the template survives + // as ONE query value; FxBlox `decodeURIComponent`s it back verbatim. + return 'token=${Uri.encodeComponent(token)}' + '&endpoint=${Uri.encodeComponent(endpoint)}' + '&returnUrl=${Uri.encodeComponent(returnTemplate)}'; +} + +/// `https://blox.fx.land/autopin-pair?token=…&endpoint=…&returnUrl=…` +/// +/// Throws [ArgumentError] on an empty [token]/[endpoint] or a +/// [returnTemplate] missing a placeholder (fail-closed: never hand FxBlox a +/// template it cannot complete). +Uri buildBloxWebPairUrl({ + required String token, + required String endpoint, + String returnTemplate = kAutopinReturnTemplate, +}) { + final q = _pairQuery( + token: token, + endpoint: endpoint, + returnTemplate: returnTemplate, + ); + return Uri.parse('$kBloxWebPairBase?$q'); +} + +/// `fxblox://autopin-pair?token=…&endpoint=…&returnUrl=…` — the SAME params +/// as [buildBloxWebPairUrl], only the scheme/host differ. +Uri buildBloxNativePairUrl({ + required String token, + required String endpoint, + String returnTemplate = kAutopinReturnTemplate, +}) { + final q = _pairQuery( + token: token, + endpoint: endpoint, + returnTemplate: returnTemplate, + ); + return Uri.parse('$kBloxNativePairBase?$q'); +} + +/// The four values FxBlox hands back after `AutoPinPair` succeeded. +/// +/// [secret] is the bearer pairing secret (required); the other three are +/// optional device identity (FxBlox sends them as empty strings when unknown, +/// which [fromMap] normalizes to null). +class AutopinCompleteParams { + const AutopinCompleteParams({ + required this.secret, + this.hardwareId, + this.bloxPeerId, + this.bloxName, + }); + + final String secret; + final String? hardwareId; + final String? bloxPeerId; + final String? bloxName; + + /// Upper bounds applied by [validationError]. Generous versus the real + /// shapes (secret: a random token; hardwareId: a hex/serial string; peer id: + /// a ~52-char base58 / CIDv1 string; name: user text) — they exist to reject + /// obviously bogus payloads before anything is persisted. + static const int maxSecretLength = 512; + static const int maxHardwareIdLength = 256; + static const int maxPeerIdLength = 128; + static const int maxNameLength = 128; + + /// Build from a decoded `key=value` map (query or fragment). Returns null + /// when there is no non-empty `secret` — the one required field. + static AutopinCompleteParams? fromMap(Map m) { + final secret = m['secret']; + if (secret == null || secret.isEmpty) return null; + return AutopinCompleteParams( + secret: secret, + hardwareId: _nullIfEmpty(m['hardwareId']), + bloxPeerId: _nullIfEmpty(m['bloxPeerId']), + bloxName: _nullIfEmpty(m['bloxName']), + ); + } + + static String? _nullIfEmpty(String? v) => (v == null || v.isEmpty) ? null : v; + + /// The `{secret, hardwareId, bloxPeerId, bloxName}` map shape the native + /// `DeepLinkService.onBloxPairingComplete` stream and `app.dart` consume. + Map toLegacyMap() => { + 'secret': secret, + 'hardwareId': hardwareId, + 'bloxPeerId': bloxPeerId, + 'bloxName': bloxName, + }; + + /// Non-null fields only — for re-emitting as a query/fragment. + Map toQueryParameters() => { + 'secret': secret, + if (hardwareId != null) 'hardwareId': hardwareId!, + if (bloxPeerId != null) 'bloxPeerId': bloxPeerId!, + if (bloxName != null) 'bloxName': bloxName!, + }; + + /// A user-safe reason this payload must NOT be persisted, or null when it + /// passes: non-empty secret, per-field length caps, no control characters. + String? get validationError { + if (secret.isEmpty) return 'Pairing secret is missing.'; + if (secret.length > maxSecretLength) return 'Pairing secret is too long.'; + if (_hasControlChars(secret)) return 'Pairing secret is malformed.'; + if ((hardwareId?.length ?? 0) > maxHardwareIdLength) { + return 'Hardware ID is too long.'; + } + if (hardwareId != null && _hasControlChars(hardwareId!)) { + return 'Hardware ID is malformed.'; + } + if ((bloxPeerId?.length ?? 0) > maxPeerIdLength) { + return 'Blox peer ID is too long.'; + } + if (bloxPeerId != null && _hasControlChars(bloxPeerId!)) { + return 'Blox peer ID is malformed.'; + } + if ((bloxName?.length ?? 0) > maxNameLength) return 'Blox name is too long.'; + if (bloxName != null && _hasControlChars(bloxName!)) { + return 'Blox name is malformed.'; + } + return null; + } + + bool get isValid => validationError == null; + + static bool _hasControlChars(String s) { + for (final cu in s.codeUnits) { + if (cu < 0x20 || cu == 0x7F) return true; + } + return false; + } + + @override + String toString() => + 'AutopinCompleteParams(secret: , ' + 'hardwareId: $hardwareId, bloxPeerId: $bloxPeerId, bloxName: $bloxName)'; +} + +/// Read the autopin-complete parameters from [uri], FRAGMENT FIRST, then the +/// query. Returns null when no non-empty `secret` is found anywhere. +/// +/// Accepted shapes (in precedence order): +/// 1. `…#secret=…&hardwareId=…` canonical fragment form +/// 2. `…/app/#/autopin-complete?secret=…` web hash-route form +/// 3. `fxfiles://autopin-complete?secret=…` / +/// `https://files.fx.land/autopin-complete?secret=…` query form +/// +/// A malformed percent-encoding in one section is treated as "absent" for that +/// section (fail-soft: fall through to the next form) rather than throwing. +AutopinCompleteParams? parseAutopinCompleteParams(Uri uri) { + final fragment = uri.fragment; + if (fragment.isNotEmpty) { + final fromFragment = _paramsFromFragment(fragment); + if (fromFragment != null) return fromFragment; + } + final query = _safeQueryParameters(uri); + if (query != null) return AutopinCompleteParams.fromMap(query); + return null; +} + +AutopinCompleteParams? _paramsFromFragment(String fragment) { + if (fragment.startsWith('/')) { + // Hash-route form: the fragment is itself a path?query. + final routed = Uri.tryParse(fragment); + if (routed == null) return null; + if (routed.path != kAutopinReturnPath && + !routed.path.endsWith(kAutopinReturnPath)) { + return null; + } + final q = _safeQueryParameters(routed); + return q == null ? null : AutopinCompleteParams.fromMap(q); + } + // Plain key=value fragment. + Map kv; + try { + kv = Uri.splitQueryString(fragment); + } catch (_) { + return null; + } + return AutopinCompleteParams.fromMap(kv); +} + +Map? _safeQueryParameters(Uri uri) { + try { + return uri.queryParameters; + } catch (_) { + return null; + } +} diff --git a/lib/core/services/deep_link_service.dart b/lib/core/services/deep_link_service.dart index 9bba1f5..a689467 100644 --- a/lib/core/services/deep_link_service.dart +++ b/lib/core/services/deep_link_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:app_links/app_links.dart'; import 'package:flutter/foundation.dart'; +import 'package:fula_files/core/services/blox_pairing_links.dart'; import 'package:fula_files/core/services/secure_storage_service.dart'; import 'package:fula_files/core/services/auth_service.dart'; import 'package:fula_files/core/services/fula_api_service.dart'; @@ -248,7 +249,7 @@ class DeepLinkService { // Handle HTTPS universal/app links from our domain if ((uri.scheme == 'https' || uri.scheme == 'http') && uri.host == _universalLinkHost) { - _handleUniversalLink(uri); + await _handleUniversalLink(uri); return; } @@ -326,7 +327,7 @@ class DeepLinkService { } /// Handle HTTPS universal links from files.fx.land - void _handleUniversalLink(Uri uri) { + Future _handleUniversalLink(Uri uri) async { final path = uri.path; if (path == '/nft-claim') { @@ -335,22 +336,37 @@ class DeepLinkService { return; } + // FxBlox → FxFiles pairing return (docs/AUTOPIN-HANDOFF.md, v1). The + // secret rides in the FRAGMENT (`/autopin-complete#secret=…`) so it never + // reaches a server; iOS universal links and Android app links both hand + // us the full URL, and the shared parser reads the fragment first, then + // the query (the forwarder's / legacy form). + if (path == kAutopinReturnPath || path == '$kAutopinReturnPath/') { + debugPrint('DeepLinkService: Blox pairing complete universal link received'); + await _handleAutoPinComplete(uri); + return; + } + debugPrint('DeepLinkService: Unknown universal link path: $path'); } + /// Complete a Blox pairing from `fxfiles://autopin-complete?…` (query) or + /// `https://files.fx.land/autopin-complete#…` (fragment). Validated before + /// anything is persisted (non-empty secret, per-field length caps, no + /// control characters — see AutopinCompleteParams.validationError). Future _handleAutoPinComplete(Uri uri) async { - final params = { - 'secret': uri.queryParameters['secret'], - 'hardwareId': uri.queryParameters['hardwareId'], - 'bloxPeerId': uri.queryParameters['bloxPeerId'], - 'bloxName': uri.queryParameters['bloxName'], - }; - - final secret = params['secret']; - if (secret == null || secret.isEmpty) { + final parsed = parseAutopinCompleteParams(uri); + if (parsed == null) { debugPrint('DeepLinkService: autopin-complete missing secret'); return; } + final validationError = parsed.validationError; + if (validationError != null) { + debugPrint('DeepLinkService: autopin-complete rejected: $validationError'); + return; + } + final params = parsed.toLegacyMap(); + final secret = parsed.secret; // Store pairing credentials await SecureStorageService.instance.write(SecureStorageKeys.bloxPairingSecret, secret); diff --git a/lib/features/settings/screens/blox_pairing_screen.dart b/lib/features/settings/screens/blox_pairing_screen.dart index 4014c5a..a3a2c36 100644 --- a/lib/features/settings/screens/blox_pairing_screen.dart +++ b/lib/features/settings/screens/blox_pairing_screen.dart @@ -1,10 +1,12 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:fula_files/app/theme/app_colors.dart'; +import 'package:fula_files/core/services/blox_pairing_links.dart'; import 'package:fula_files/core/services/secure_storage_service.dart'; import 'package:fula_files/core/services/blox_discovery_service.dart'; import 'package:fula_files/core/services/fula_api_service.dart'; @@ -329,7 +331,7 @@ class _BloxPairingScreenState extends State { // Get the JWT token for the pinning service final jwtToken = await SecureStorageService.instance.read(SecureStorageKeys.jwtToken); final ipfsServer = await SecureStorageService.instance.read(SecureStorageKeys.ipfsServerUrl) - ?? 'https://api.cloud.fx.land'; + ?? kDefaultPinningEndpoint; if (jwtToken == null || jwtToken.isEmpty) { if (mounted) { @@ -340,27 +342,83 @@ class _BloxPairingScreenState extends State { return; } - // Build deeplink URL to FxBlox app - final token = Uri.encodeComponent(jwtToken); - final endpoint = Uri.encodeComponent(ipfsServer); - final returnUrl = Uri.encodeComponent( - 'fxfiles://autopin-complete?secret=\$secret&hardwareId=\$hardwareId&bloxPeerId=\$bloxPeerId&bloxName=\$bloxName', - ); + // Outbound hand-off links (docs/AUTOPIN-HANDOFF.md): the SAME + // token/endpoint/returnUrl-template params on two carriers — the FxBlox + // app deep link and the web FxBlox at blox.fx.land. The return template + // (kAutopinReturnTemplate) is the https FRAGMENT form, so the pairing + // secret FxBlox hands back never reaches a server; the four + // `$placeholders` are literal and substituted by FxBlox. + final webUrl = buildBloxWebPairUrl(token: jwtToken, endpoint: ipfsServer); + + if (kIsWeb) { + // The web shell has its own dart:io-free screen (web_blox_pairing_screen) + // so this branch is not reached today; keep the contract explicit: in a + // browser the web FxBlox is the only target. + await _openInBrowser(webUrl); + return; + } + + // Native: try the FxBlox app first … + final deeplinkUrl = buildBloxNativePairUrl(token: jwtToken, endpoint: ipfsServer); + var launched = false; + try { + launched = await launchUrl(deeplinkUrl, mode: LaunchMode.externalApplication); + } catch (e) { + // url_launcher throws (Android: no activity for the scheme) or returns + // false (iOS: canOpenURL false) when FxBlox is not installed — both mean + // the same thing here. + debugPrint('BloxPairing: fxblox:// launch failed: $e'); + } + if (launched || !mounted) return; - final deeplinkUrl = 'fxblox://autopin-pair?token=$token&endpoint=$endpoint&returnUrl=$returnUrl'; + // … and fall back to pairing in the browser. + await _offerPairInBrowser(webUrl); + } + /// FxBlox app not available → let the user pair through blox.fx.land + /// instead (replaces the old "app not installed" dead-end snackbar). + Future _offerPairInBrowser(Uri webUrl) async { + final choice = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('FxBlox app not found'), + content: const Text( + 'Install the FxBlox app from the app store, or pair in your browser ' + 'at blox.fx.land instead. Your browser brings you back to FxFiles ' + 'when pairing is done.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton.icon( + onPressed: () => Navigator.pop(ctx, true), + icon: const Icon(LucideIcons.globe, size: 18), + label: const Text('Pair in browser'), + ), + ], + ), + ); + if (choice == true && mounted) await _openInBrowser(webUrl); + } + + Future _openInBrowser(Uri webUrl) async { try { - final uri = Uri.parse(deeplinkUrl); - final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); - if (!launched && mounted) { + final ok = await launchUrl( + webUrl, + mode: LaunchMode.externalApplication, + webOnlyWindowName: '_self', + ); + if (!ok && mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('FxBlox app not installed. Please install it from the app store.')), + const SnackBar(content: Text('Could not open blox.fx.land.')), ); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(ErrorMessages.getUserFriendlyMessage(e, context: 'launch FxBlox'))), + SnackBar(content: Text(ErrorMessages.getUserFriendlyMessage(e, context: 'open blox.fx.land'))), ); } } @@ -631,7 +689,7 @@ class _BloxPairingScreenState extends State { Future _showManualPairingDialog() async { final jwtToken = await SecureStorageService.instance.read(SecureStorageKeys.jwtToken); final ipfsEndpoint = await SecureStorageService.instance.read(SecureStorageKeys.ipfsServerUrl) - ?? 'https://api.cloud.fx.land'; + ?? kDefaultPinningEndpoint; if (!mounted) return; @@ -899,6 +957,40 @@ class _ManualPairingDialogState extends State<_ManualPairingDialog> { ); } + /// Desktop alternative to the QR + paste flow: open the web FxBlox + /// (blox.fx.land) with the same hand-off params. When it finishes, the + /// browser lands on files.fx.land/autopin-complete → "Open in FxFiles" + /// (`fxfiles://autopin-complete?…`) → DeepLinkService completes the + /// pairing, so this dialog closes itself once the browser is open. + Future _pairInBrowser() async { + final token = widget.jwtToken; + if (token == null || token.isEmpty) return; + final url = buildBloxWebPairUrl(token: token, endpoint: widget.ipfsEndpoint); + final messenger = ScaffoldMessenger.maybeOf(context); + try { + final ok = await launchUrl(url, mode: LaunchMode.externalApplication); + if (!ok) { + messenger?.showSnackBar( + const SnackBar(content: Text('Could not open blox.fx.land.')), + ); + return; + } + } catch (e) { + messenger?.showSnackBar( + SnackBar(content: Text(ErrorMessages.getUserFriendlyMessage(e, context: 'open blox.fx.land'))), + ); + return; + } + if (!mounted) return; + messenger?.showSnackBar( + const SnackBar( + content: Text('Finish pairing in your browser — FxFiles opens automatically when it is done.'), + duration: Duration(seconds: 6), + ), + ); + Navigator.pop(context); + } + @override Widget build(BuildContext context) { final qrData = _qrData; @@ -1047,6 +1139,26 @@ class _ManualPairingDialogState extends State<_ManualPairingDialog> { _buildStep(3, 'Tap "Scan QR Code" and scan this code'), _buildStep(4, 'Tap "Get Secret"'), _buildStep(5, 'Copy the shown secret (it is only shown once) and paste it in the "Pairing Secret" field'), + const SizedBox(height: 16), + Text( + 'No phone handy?', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: qrData == null ? null : _pairInBrowser, + icon: const Icon(LucideIcons.globe, size: 18), + label: const Text('Pair in browser'), + ), + const SizedBox(height: 4), + Text( + 'Opens blox.fx.land; it brings you back to FxFiles when pairing is done.', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), ], ); } diff --git a/site/.well-known/apple-app-site-association b/site/.well-known/apple-app-site-association index e5b0fe1..659e099 100644 --- a/site/.well-known/apple-app-site-association +++ b/site/.well-known/apple-app-site-association @@ -4,7 +4,7 @@ "details": [ { "appID": "656TD8GM9B.land.fx.files", - "paths": ["/nft-claim*"] + "paths": ["/nft-claim*", "/autopin-complete*"] } ] } diff --git a/test/unit/core/services/blox_pairing_links_test.dart b/test/unit/core/services/blox_pairing_links_test.dart new file mode 100644 index 0000000..3206575 --- /dev/null +++ b/test/unit/core/services/blox_pairing_links_test.dart @@ -0,0 +1,316 @@ +// Unit tests for the Blox auto-pin pairing hand-off links +// (`docs/AUTOPIN-HANDOFF.md` v1). Pure Dart — runs on the VM. +// +// Covered: +// 1. Outbound URL building (web + native): base, param set, encoding of the +// template as ONE query value, round-trip decode back to the template, +// placeholder presence, fail-closed ArgumentErrors. +// 2. The return template constants: fragment form, all four `$placeholders` +// kept LITERAL (no Dart interpolation), legacy template shape. +// 3. parseAutopinCompleteParams: fragment-first precedence over query, the +// hash-route form, the legacy query form, empty-optional normalization, +// malformed encodings failing soft, and no-secret → null. +// 4. AutopinCompleteParams validation: lengths, control chars, map shapes. + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fula_files/core/services/blox_pairing_links.dart'; + +void main() { + const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1In0.sig+/='; + const endpoint = 'https://api.cloud.fx.land'; + + // =========================================================================== + // 1. Outbound URL building. + // =========================================================================== + group('buildBloxWebPairUrl / buildBloxNativePairUrl', () { + test('web URL targets blox.fx.land/autopin-pair with the three params', () { + final uri = buildBloxWebPairUrl(token: token, endpoint: endpoint); + expect(uri.scheme, 'https'); + expect(uri.host, 'blox.fx.land'); + expect(uri.path, '/autopin-pair'); + expect(uri.queryParameters.keys.toSet(), + {'token', 'endpoint', 'returnUrl'}); + expect(uri.queryParameters['token'], token); + expect(uri.queryParameters['endpoint'], endpoint); + expect(uri.queryParameters['returnUrl'], kAutopinReturnTemplate); + }); + + test('native URL is fxblox://autopin-pair with the IDENTICAL query', () { + final web = buildBloxWebPairUrl(token: token, endpoint: endpoint); + final native = buildBloxNativePairUrl(token: token, endpoint: endpoint); + expect(native.scheme, 'fxblox'); + expect(native.host, 'autopin-pair'); + expect(native.query, web.query); + }); + + test('the template is percent-encoded as ONE value (no raw & # \$ =)', () { + final uri = buildBloxWebPairUrl(token: token, endpoint: endpoint); + final raw = uri.toString(); + final returnUrlRaw = raw.substring(raw.indexOf('returnUrl=') + 10); + expect(returnUrlRaw, isNot(contains('&'))); + expect(returnUrlRaw, isNot(contains('#'))); + expect(returnUrlRaw, isNot(contains(r'$'))); + expect(returnUrlRaw, isNot(contains('='))); + expect(returnUrlRaw, contains('%24secret')); + expect(returnUrlRaw, contains('%23secret%3D')); + // Exactly the string FxBlox `decodeURIComponent`s back to the template. + expect(Uri.decodeComponent(returnUrlRaw), kAutopinReturnTemplate); + }); + + test('token characters that are special in URLs survive the round trip', + () { + const nasty = 'a b&c=d#e/f?g+h%i\$j'; + final uri = buildBloxWebPairUrl(token: nasty, endpoint: endpoint); + expect(uri.queryParameters['token'], nasty); + }); + + test('a custom template is accepted when it has every placeholder', () { + final uri = buildBloxWebPairUrl( + token: token, + endpoint: endpoint, + returnTemplate: kAutopinLegacyReturnTemplate, + ); + expect(uri.queryParameters['returnUrl'], kAutopinLegacyReturnTemplate); + }); + + test('fails closed on an empty token / endpoint', () { + expect(() => buildBloxWebPairUrl(token: '', endpoint: endpoint), + throwsArgumentError); + expect(() => buildBloxNativePairUrl(token: token, endpoint: ''), + throwsArgumentError); + }); + + test('fails closed on a template missing a placeholder', () { + expect( + () => buildBloxWebPairUrl( + token: token, + endpoint: endpoint, + returnTemplate: r'https://files.fx.land/autopin-complete#secret=$secret', + ), + throwsArgumentError, + ); + }); + }); + + // =========================================================================== + // 2. Template constants. + // =========================================================================== + group('return template constants', () { + test('canonical template is the https fragment form', () { + expect( + kAutopinReturnTemplate, + r'https://files.fx.land/autopin-complete#secret=$secret&hardwareId=$hardwareId&bloxPeerId=$bloxPeerId&bloxName=$bloxName', + ); + final u = Uri.parse(kAutopinReturnTemplate); + expect(u.scheme, 'https'); + expect(u.host, kAutopinReturnHost); + expect(u.path, kAutopinReturnPath); + expect(u.query, isEmpty, reason: 'the secret must NOT be in the query'); + expect(u.fragment, startsWith(r'secret=$secret')); + }); + + test('all four placeholders are present, literal, and exactly once', () { + expect(returnTemplateHasAllPlaceholders(kAutopinReturnTemplate), isTrue); + expect(returnTemplateHasAllPlaceholders(kAutopinLegacyReturnTemplate), + isTrue); + for (final p in kAutopinReturnPlaceholders) { + expect(p, startsWith(r'$')); + expect(r'$'.allMatches(p).length, 1); + expect(kAutopinReturnTemplate.split(p).length - 1, 1, + reason: '$p must appear exactly once'); + } + expect(kAutopinReturnPlaceholders, + [r'$secret', r'$hardwareId', r'$bloxPeerId', r'$bloxName']); + }); + + test('legacy template keeps the fxfiles:// query shape', () { + final u = Uri.parse(kAutopinLegacyReturnTemplate); + expect(u.scheme, 'fxfiles'); + expect(u.host, 'autopin-complete'); + expect(u.queryParameters['secret'], r'$secret'); + }); + + test('returnTemplateHasAllPlaceholders rejects a partial template', () { + expect(returnTemplateHasAllPlaceholders(r'x#secret=$secret&bloxName=$bloxName'), + isFalse); + }); + }); + + // =========================================================================== + // 3. parseAutopinCompleteParams. + // =========================================================================== + group('parseAutopinCompleteParams', () { + test('canonical fragment form (what FxBlox substitutes into v1)', () { + final p = parseAutopinCompleteParams(Uri.parse( + 'https://files.fx.land/autopin-complete#secret=s3cr3t&hardwareId=hw1&bloxPeerId=12D3KooW&bloxName=My%20Blox')); + expect(p, isNotNull); + expect(p!.secret, 's3cr3t'); + expect(p.hardwareId, 'hw1'); + expect(p.bloxPeerId, '12D3KooW'); + expect(p.bloxName, 'My Blox'); + }); + + test('web hash-route form (#/autopin-complete?…)', () { + final p = parseAutopinCompleteParams(Uri.parse( + 'https://files.fx.land/app/#/autopin-complete?secret=abc&hardwareId=hw&bloxPeerId=pid&bloxName=Nm')); + expect(p, isNotNull); + expect(p!.secret, 'abc'); + expect(p.hardwareId, 'hw'); + expect(p.bloxPeerId, 'pid'); + expect(p.bloxName, 'Nm'); + }); + + test('a hash route that is NOT /autopin-complete is ignored', () { + final p = parseAutopinCompleteParams( + Uri.parse('https://files.fx.land/app/#/settings?secret=abc')); + expect(p, isNull); + }); + + test('legacy fxfiles:// query form', () { + final p = parseAutopinCompleteParams(Uri.parse( + 'fxfiles://autopin-complete?secret=q1&hardwareId=h&bloxPeerId=p&bloxName=n')); + expect(p, isNotNull); + expect(p!.secret, 'q1'); + expect(p.bloxName, 'n'); + }); + + test('https query form (forwarder fallback)', () { + final p = parseAutopinCompleteParams( + Uri.parse('https://files.fx.land/autopin-complete?secret=q2')); + expect(p?.secret, 'q2'); + }); + + test('fragment WINS over query when both carry a secret', () { + final p = parseAutopinCompleteParams(Uri.parse( + 'https://files.fx.land/autopin-complete?secret=fromQuery&bloxName=Q#secret=fromFragment&bloxName=F')); + expect(p!.secret, 'fromFragment'); + expect(p.bloxName, 'F'); + }); + + test('a fragment WITHOUT a secret falls through to the query', () { + final p = parseAutopinCompleteParams(Uri.parse( + 'https://files.fx.land/autopin-complete?secret=fromQuery#other=1')); + expect(p!.secret, 'fromQuery'); + }); + + test('empty optional fields normalize to null (FxBlox sends hardwareId=)', + () { + final p = parseAutopinCompleteParams(Uri.parse( + 'https://files.fx.land/autopin-complete#secret=s&hardwareId=&bloxPeerId=&bloxName=')); + expect(p!.hardwareId, isNull); + expect(p.bloxPeerId, isNull); + expect(p.bloxName, isNull); + }); + + test('percent-encoded values are decoded once', () { + final p = parseAutopinCompleteParams(Uri.parse( + 'https://files.fx.land/autopin-complete#secret=a%2Bb%3D%26c&bloxName=Caf%C3%A9')); + expect(p!.secret, 'a+b=&c'); + expect(p.bloxName, 'Café'); + }); + + test('no secret anywhere → null', () { + expect( + parseAutopinCompleteParams( + Uri.parse('https://files.fx.land/autopin-complete')), + isNull); + expect( + parseAutopinCompleteParams(Uri.parse( + 'https://files.fx.land/autopin-complete?hardwareId=h#bloxName=n')), + isNull); + expect( + parseAutopinCompleteParams( + Uri.parse('https://files.fx.land/autopin-complete?secret=')), + isNull); + }); + + test('a malformed fragment encoding fails soft to the query', () { + final p = parseAutopinCompleteParams( + Uri.parse('https://files.fx.land/x?secret=ok#secret=%E0%A4%A')); + expect(p?.secret, 'ok'); + }); + }); + + // =========================================================================== + // 4. AutopinCompleteParams. + // =========================================================================== + group('AutopinCompleteParams', () { + test('fromMap requires a non-empty secret', () { + expect(AutopinCompleteParams.fromMap(const {}), isNull); + expect(AutopinCompleteParams.fromMap(const {'secret': ''}), isNull); + expect(AutopinCompleteParams.fromMap(const {'secret': 'x'})?.secret, 'x'); + }); + + test('a sane payload validates', () { + const p = AutopinCompleteParams( + secret: 'd41d8cd98f00b204e9800998ecf8427e', + hardwareId: 'ABC123', + bloxPeerId: '12D3KooWQYhTNQdmr3ArTeUHRYzFg94BKyTkoWBDWez9kSCVe2Xo', + bloxName: 'Living room Blox', + ); + expect(p.validationError, isNull); + expect(p.isValid, isTrue); + }); + + test('length caps are enforced per field', () { + expect( + AutopinCompleteParams(secret: 'a' * 513).validationError, isNotNull); + expect(AutopinCompleteParams(secret: 'a' * 512).validationError, isNull); + expect( + AutopinCompleteParams(secret: 's', hardwareId: 'h' * 257) + .validationError, + isNotNull); + expect( + AutopinCompleteParams(secret: 's', bloxPeerId: 'p' * 129) + .validationError, + isNotNull); + expect( + AutopinCompleteParams(secret: 's', bloxName: 'n' * 129) + .validationError, + isNotNull); + }); + + test('control characters are rejected in every field', () { + expect(const AutopinCompleteParams(secret: 'a\nb').validationError, + isNotNull); + expect( + const AutopinCompleteParams(secret: 's', hardwareId: 'h\x00') + .validationError, + isNotNull); + expect( + const AutopinCompleteParams(secret: 's', bloxPeerId: 'p\x7f') + .validationError, + isNotNull); + expect( + const AutopinCompleteParams(secret: 's', bloxName: 'n\tm') + .validationError, + isNotNull); + // Unicode text is fine. + expect( + const AutopinCompleteParams(secret: 's', bloxName: 'Büro 🏠') + .validationError, + isNull); + }); + + test('toLegacyMap keeps the DeepLinkService shape (nulls preserved)', () { + const p = AutopinCompleteParams(secret: 's', bloxName: 'n'); + expect(p.toLegacyMap(), { + 'secret': 's', + 'hardwareId': null, + 'bloxPeerId': null, + 'bloxName': 'n', + }); + }); + + test('toQueryParameters omits nulls', () { + const p = AutopinCompleteParams(secret: 's', hardwareId: 'h'); + expect(p.toQueryParameters(), {'secret': 's', 'hardwareId': 'h'}); + }); + + test('toString never leaks the secret', () { + const p = AutopinCompleteParams(secret: 'super-secret-value'); + expect(p.toString(), isNot(contains('super-secret-value'))); + }); + }); +} From a04ef9793dabad63df9e2b4b7bc6dc60253a5bed Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Thu, 27 Aug 2026 09:55:50 -0400 Subject: [PATCH 2/5] feat(web): Blox pairing return receiver + My Devices screen captureAutopinReturn() runs in main_web.dart BEFORE runApp: it reads the location, stashes the params (memory + sessionStorage so a refresh mid-sign-in does not lose them) and history.replaceState-strips them to redirect cannot drop it. The web home's post-login init takes the pending return and navigates to /blox-pairing with the params as go_router extra (never a query). /autopin-complete also exists as a router fallback (logged out: the redirect parks the params; signed in: the screen persists them then cleans the URL). New dart:io-free lib/web/screens/web_blox_pairing_screen.dart: shows the paired state from SecureStorageKeys.blox*, validates + writes incoming params, "Pair Blox" opens buildBloxWebPairUrl(...) in the same tab, "Unpair" clears the keys, reveal/copy of the secret behind a confirm, and the inline LAN-gateway limitation note. Settings gains a "My Devices" section linking to /blox-pairing. The pure parsing/stripping/session-encoding lives in web_autopin_return_logic.dart (VM-tested) behind the same conditional-export pattern as web_hosted_oauth. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QyQw3WtVXDTtvJKN7ykczw --- lib/main_web.dart | 8 + lib/web/router_web.dart | 38 ++ lib/web/screens/web_blox_pairing_screen.dart | 475 ++++++++++++++++++ lib/web/screens/web_home_screen.dart | 19 + lib/web/screens/web_settings_screen.dart | 19 + lib/web/services/web_autopin_return.dart | 14 + lib/web/services/web_autopin_return_io.dart | 23 + .../services/web_autopin_return_logic.dart | 169 +++++++ lib/web/services/web_autopin_return_web.dart | 86 ++++ .../web/web_autopin_return_logic_test.dart | 210 ++++++++ 10 files changed, 1061 insertions(+) create mode 100644 lib/web/screens/web_blox_pairing_screen.dart create mode 100644 lib/web/services/web_autopin_return.dart create mode 100644 lib/web/services/web_autopin_return_io.dart create mode 100644 lib/web/services/web_autopin_return_logic.dart create mode 100644 lib/web/services/web_autopin_return_web.dart create mode 100644 test/unit/web/web_autopin_return_logic_test.dart diff --git a/lib/main_web.dart b/lib/main_web.dart index efd2171..5c8c2ba 100644 --- a/lib/main_web.dart +++ b/lib/main_web.dart @@ -46,6 +46,7 @@ import 'package:fula_files/core/services/ipfs_gateway_helper.dart'; import 'package:fula_files/core/services/secure_storage_service.dart'; import 'package:fula_files/core/services/share_link_builder.dart'; import 'package:fula_files/web/app_web.dart'; +import 'package:fula_files/web/services/web_autopin_return.dart'; import 'package:fula_files/web/services/web_breaker_persistence.dart'; import 'package:fula_files/web/services/web_device_class.dart'; import 'package:fula_files/web/services/web_features.dart'; @@ -78,6 +79,13 @@ Future main() async { // stays false there): it has its own block cache and storage tiers. BucketHealthBreaker.enabled = true; + // CAPTURE (Blox pairing return): read `#/autopin-complete?secret=…` (or the + // bare-fragment / query forms) from the location, stash the params, and + // strip them from the URL BEFORE the hash router boots — its logged-out + // redirect would otherwise bounce to `/` and drop them. The params are + // consumed post-login by the web home (→ /blox-pairing). No-op otherwise. + captureAutopinReturn(); + Object? bootError; var restored = false; try { diff --git a/lib/web/router_web.dart b/lib/web/router_web.dart index e457af2..436fa55 100644 --- a/lib/web/router_web.dart +++ b/lib/web/router_web.dart @@ -1,5 +1,6 @@ import 'package:go_router/go_router.dart'; +import 'package:fula_files/core/services/blox_pairing_links.dart'; import 'package:fula_files/core/services/wallet_service.dart' show walletNavigatorKey; import 'package:fula_files/web/screens/web_api_config_screen.dart'; @@ -7,6 +8,7 @@ import 'package:fula_files/web/screens/web_buffer_settings_screen.dart'; import 'package:fula_files/web/screens/web_automate_task_detail_screen.dart'; import 'package:fula_files/web/screens/web_automate_task_run_screen.dart'; import 'package:fula_files/web/screens/web_automate_tasks_screen.dart'; +import 'package:fula_files/web/screens/web_blox_pairing_screen.dart'; import 'package:fula_files/web/screens/web_bucket_screen.dart'; import 'package:fula_files/web/screens/web_cloud_files_screen.dart'; import 'package:fula_files/web/screens/web_collab_detail_screen.dart'; @@ -24,6 +26,7 @@ import 'package:fula_files/web/screens/web_sync_queue_screen.dart'; import 'package:fula_files/web/screens/web_tags_screen.dart'; import 'package:fula_files/web/screens/web_website_detail_screen.dart'; import 'package:fula_files/web/screens/web_websites_screen.dart'; +import 'package:fula_files/web/services/web_autopin_return.dart'; import 'package:fula_files/web/services/web_session.dart'; /// Web-shell router. Deliberately defines ONLY the cloud routes — the @@ -44,6 +47,16 @@ GoRouter buildWebRouter() { redirect: (context, state) { final signedIn = WebSession.instance.isSignedIn; final loc = state.matchedLocation; + // Blox pairing return (`#/autopin-complete?secret=…`). main() normally + // captures + strips this BEFORE the router mounts, so reaching here is + // the replaceState-failed fallback. Logged out: park the params for the + // post-login hand-off (web home init) instead of the blind bounce below + // that would drop them; signed in: fall through and let the route + // render (it persists the params and then cleans the URL). + if (loc == '/autopin-complete' && !signedIn) { + stashPendingAutopinReturn(parseAutopinCompleteParams(state.uri)); + return '/'; + } if (signedIn) { // Once authenticated, the standalone sign-in screen has no purpose. return loc == '/signin' ? '/' : null; @@ -132,6 +145,31 @@ GoRouter buildWebRouter() { ); }, ), + // Blox pairing (Settings → My Devices). The post-login hand-off from the + // web home lands here with the FxBlox return as `extra` (never a query, + // so the secret stays out of the address bar); a query is accepted too + // for robustness. + GoRoute( + path: '/blox-pairing', + builder: (context, state) { + final extra = state.extra; + return WebBloxPairingScreen( + incoming: extra is AutopinCompleteParams + ? extra + : parseAutopinCompleteParams(state.uri), + ); + }, + ), + // Fallback receiver for `#/autopin-complete?secret=…` when main()'s + // capture could not strip the URL (see the redirect above): persist, + // then move to /blox-pairing so the secret leaves the URL. + GoRoute( + path: '/autopin-complete', + builder: (context, state) => WebBloxPairingScreen( + incoming: parseAutopinCompleteParams(state.uri), + fromReturnUrl: true, + ), + ), GoRoute( path: '/playlist/:id', builder: (context, state) => WebPlaylistDetailScreen( diff --git a/lib/web/screens/web_blox_pairing_screen.dart b/lib/web/screens/web_blox_pairing_screen.dart new file mode 100644 index 0000000..322b85a --- /dev/null +++ b/lib/web/screens/web_blox_pairing_screen.dart @@ -0,0 +1,475 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:fula_files/core/services/blox_pairing_links.dart'; +import 'package:fula_files/core/services/secure_storage_service.dart'; +import 'package:fula_files/web/services/web_autopin_return.dart'; + +/// Web "My Devices" — the dart:io-free counterpart of the native +/// `BloxPairingScreen` (which needs mDNS/NSD and the LAN gateway, neither of +/// which exists in a browser). +/// +/// What it does: +/// - shows the paired state from `SecureStorageKeys.blox*`; +/// - when opened with [incoming] params (the FxBlox → FxFiles return, handed +/// off from the web home as go_router `extra`, or from the +/// `/autopin-complete` fallback route's query), VALIDATES them and writes +/// the four keys; +/// - "Pair Blox" opens `https://blox.fx.land/autopin-pair?…` in THIS tab +/// (same-tab hand-off; the page unloads and FxBlox brings the user back); +/// - "Unpair" clears the keys. +/// +/// Reached via `/blox-pairing` (Settings → My Devices) and `/autopin-complete`. +class WebBloxPairingScreen extends StatefulWidget { + const WebBloxPairingScreen({ + super.key, + this.incoming, + this.fromReturnUrl = false, + }); + + /// The FxBlox return payload to persist, if any. + final AutopinCompleteParams? incoming; + + /// True when the route's URL itself carries the params (`/autopin-complete + /// ?secret=…` fallback). After persisting, the screen navigates to + /// `/blox-pairing` so the secret leaves the address bar. + final bool fromReturnUrl; + + @override + State createState() => _WebBloxPairingScreenState(); +} + +class _WebBloxPairingScreenState extends State { + bool _loading = true; + bool _paired = false; + String? _secret; + String? _hardwareId; + String? _peerId; + String? _name; + bool _revealSecret = false; + bool _launching = false; + + /// A one-line status/error shown above the sections (e.g. a rejected + /// return payload). Null when there is nothing to say. + String? _notice; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final incoming = widget.incoming; + if (incoming != null) { + // Drain the pending holder regardless of how we were reached, so a + // later home mount cannot replay the same hand-off. + takePendingAutopinReturn(); + final err = incoming.validationError; + if (err != null) { + _notice = 'Pairing data from FxBlox was rejected: $err'; + } else { + await _persist(incoming); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Blox paired successfully')), + ); + if (widget.fromReturnUrl) { + // Clean URL: the fresh /blox-pairing screen re-reads storage. + context.go('/blox-pairing'); + return; + } + } + } + await _readStored(); + } + + Future _persist(AutopinCompleteParams p) async { + final s = SecureStorageService.instance; + await s.write(SecureStorageKeys.bloxPairingSecret, p.secret); + // A fresh pairing replaces the whole identity — clear a field the new + // payload does not carry rather than leaving a stale value behind. + await _writeOrDelete(s, SecureStorageKeys.bloxHardwareId, p.hardwareId); + await _writeOrDelete(s, SecureStorageKeys.bloxPeerId, p.bloxPeerId); + await _writeOrDelete(s, SecureStorageKeys.bloxName, p.bloxName); + } + + Future _writeOrDelete( + SecureStorageService s, String key, String? value) async { + if (value == null || value.isEmpty) { + await s.delete(key); + } else { + await s.write(key, value); + } + } + + Future _readStored() async { + final s = SecureStorageService.instance; + final secret = await s.read(SecureStorageKeys.bloxPairingSecret); + final hw = await s.read(SecureStorageKeys.bloxHardwareId); + final peer = await s.read(SecureStorageKeys.bloxPeerId); + final name = await s.read(SecureStorageKeys.bloxName); + if (!mounted) return; + setState(() { + _secret = secret; + _paired = secret != null && secret.isNotEmpty; + _hardwareId = hw; + _peerId = peer; + _name = name; + _revealSecret = false; + _loading = false; + }); + } + + /// Open the web FxBlox pairing page in THIS tab. FxBlox calls the Blox's + /// AutoPinPair with our cloud JWT and brings us back through + /// files.fx.land/autopin-complete (the fragment return template). + Future _pair() async { + if (_launching) return; + setState(() => _launching = true); + try { + final jwt = await SecureStorageService.instance + .read(SecureStorageKeys.jwtToken); + final endpoint = await SecureStorageService.instance + .read(SecureStorageKeys.ipfsServerUrl) ?? + kDefaultPinningEndpoint; + if (jwt == null || jwt.isEmpty) { + _snack('Sign in first — your cloud API key is needed to pair a Blox.'); + return; + } + final url = buildBloxWebPairUrl( + token: jwt, + endpoint: endpoint.isEmpty ? kDefaultPinningEndpoint : endpoint, + ); + final ok = await launchUrl(url, webOnlyWindowName: '_self'); + if (!ok) _snack('Could not open blox.fx.land.'); + } catch (e) { + _snack('Could not start pairing: $e'); + } finally { + if (mounted) setState(() => _launching = false); + } + } + + Future _unpair() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Unpair Blox?'), + content: const Text( + 'This removes the pairing credentials stored in this browser. ' + 'Your Blox keeps auto-pinning until you remove FxFiles from its ' + 'Auto-Pin Pairing list in FxBlox.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Unpair')), + ], + ), + ); + if (confirmed != true) return; + final s = SecureStorageService.instance; + for (final k in const [ + SecureStorageKeys.bloxPairingSecret, + SecureStorageKeys.bloxHardwareId, + SecureStorageKeys.bloxPeerId, + SecureStorageKeys.bloxName, + SecureStorageKeys.bloxIpOverride, + SecureStorageKeys.bloxLastKnownIp, + ]) { + await s.delete(k); + } + await _readStored(); + } + + Future _confirmReveal() async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Reveal pairing secret?'), + content: const Text( + 'The pairing secret lets an app read files from your Blox over your ' + 'local network. Reveal it only somewhere private — for example to ' + 'paste it into FxFiles desktop → My Devices → Pair → "Pairing Secret".', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel')), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('Reveal')), + ], + ), + ); + if (ok == true && mounted) setState(() => _revealSecret = true); + } + + Future _copy(String value, String label) async { + await Clipboard.setData(ClipboardData(text: value)); + _snack('$label copied'); + } + + void _snack(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 3)), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: 'Back', + onPressed: () => + context.canPop() ? context.pop() : context.go('/settings'), + ), + title: const Text('My Devices'), + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : Center( + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + if (_notice != null) _noticeBanner(context), + if (_paired) ...[ + _pairedSection(context), + const Divider(height: 1), + _manageSection(context), + ] else + _unpairedSection(context), + const Divider(height: 1), + _limitationSection(context), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } + + Widget _noticeBanner(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(LucideIcons.alertTriangle, color: scheme.onErrorContainer), + const SizedBox(width: 10), + Expanded( + child: Text(_notice!, + style: TextStyle(color: scheme.onErrorContainer)), + ), + ], + ), + ), + ); + } + + // ── Paired ─────────────────────────────────────────────────────────── + Widget _pairedSection(BuildContext context) { + final mono = const TextStyle(fontFamily: 'monospace', fontSize: 12); + return _Section( + label: 'PAIRED BLOX', + children: [ + ListTile( + leading: Icon(LucideIcons.hardDrive, + color: Theme.of(context).colorScheme.primary), + title: Text(_name ?? 'Blox Device'), + subtitle: const Text('Paired — auto-pinning your cloud files'), + trailing: const Icon(LucideIcons.checkCircle2, color: Colors.green), + ), + if (_hardwareId != null) + ListTile( + leading: const Icon(LucideIcons.fingerprint), + title: const Text('Hardware ID'), + subtitle: SelectableText(_hardwareId!, style: mono), + ), + if (_peerId != null) + ListTile( + leading: const Icon(LucideIcons.radio), + title: const Text('Peer ID'), + subtitle: SelectableText(_peerId!, style: mono), + ), + ListTile( + leading: const Icon(LucideIcons.key), + title: const Text('Pairing secret'), + subtitle: _revealSecret + ? SelectableText(_secret ?? '', style: mono, maxLines: 2) + : const Text('Hidden — reveal to copy into FxFiles desktop'), + trailing: _revealSecret + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Copy', + icon: const Icon(Icons.copy, size: 18), + onPressed: () => _copy(_secret ?? '', 'Pairing secret'), + ), + IconButton( + tooltip: 'Hide', + icon: const Icon(Icons.visibility_off_outlined), + onPressed: () => setState(() => _revealSecret = false), + ), + ], + ) + : IconButton( + tooltip: 'Reveal', + icon: const Icon(Icons.visibility_outlined), + onPressed: _confirmReveal, + ), + ), + ], + ); + } + + Widget _manageSection(BuildContext context) { + return _Section( + label: 'MANAGE', + children: [ + ListTile( + leading: const Icon(LucideIcons.refreshCw), + title: const Text('Pair again'), + subtitle: const Text('Re-run pairing on blox.fx.land (new secret)'), + trailing: _launching + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.north_east, size: 16), + onTap: _launching ? null : _pair, + ), + ListTile( + leading: const Icon(LucideIcons.unlink, color: Colors.red), + title: const Text('Unpair device', + style: TextStyle(color: Colors.red)), + subtitle: const Text('Remove the pairing stored in this browser'), + onTap: _unpair, + ), + ], + ); + } + + // ── Not paired ─────────────────────────────────────────────────────── + Widget _unpairedSection(BuildContext context) { + return _Section( + label: 'MY DEVICES', + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(LucideIcons.hardDrive, + size: 32, color: Colors.grey[500]), + const SizedBox(width: 12), + Text('No Blox paired', + style: Theme.of(context).textTheme.titleMedium), + ], + ), + const SizedBox(height: 8), + Text( + 'Pair your Blox so it automatically pins (keeps a local copy ' + 'of) the files you upload to the cloud. Pairing opens ' + 'blox.fx.land in this tab; when it finishes you are brought ' + 'back here.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.icon( + onPressed: _launching ? null : _pair, + icon: _launching + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(LucideIcons.link, size: 18), + label: const Text('Pair Blox'), + ), + ), + ], + ), + ), + ], + ); + } + + // ── Limitation (documented in README + docs/AUTOPIN-HANDOFF.md) ────── + Widget _limitationSection(BuildContext context) { + return _Section( + label: 'GOOD TO KNOW', + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + 'The web app cannot talk to your Blox over your local network ' + '(browsers block http:// from an https page and have no ' + 'mDNS discovery), so files here always come from the cloud. ' + 'Pairing still makes the Blox auto-pin your files. The pairing ' + 'credentials are stored only in this browser; to get fast LAN ' + 'downloads, pair in FxFiles on your phone or desktop too (desktop ' + 'accepts the pairing secret above).', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + } +} + +class _Section extends StatelessWidget { + final String label; + final List children; + const _Section({required this.label, required this.children}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 12, 0, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + letterSpacing: 1, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ...children, + ], + ), + ); + } +} diff --git a/lib/web/screens/web_home_screen.dart b/lib/web/screens/web_home_screen.dart index 73e971d..9345c1f 100644 --- a/lib/web/screens/web_home_screen.dart +++ b/lib/web/screens/web_home_screen.dart @@ -8,6 +8,7 @@ import 'package:fula_files/core/models/billing/storage_info.dart'; import 'package:fula_files/core/models/user_rank.dart'; import 'package:fula_files/core/services/billing_api_service.dart'; import 'package:fula_files/web/screens/web_settings_screen.dart' show kWebAppVersion; +import 'package:fula_files/web/services/web_autopin_return.dart'; import 'package:fula_files/web/services/web_prefetch_scheduler.dart'; import 'package:fula_files/web/services/web_session.dart'; import 'package:fula_files/web/widgets/web_login_bar.dart'; @@ -67,6 +68,24 @@ class _WebHomeScreenState extends State { // Self-delays per device class (§8.1), yields to foreground ops, and is // idempotent across home revisits. WebPrefetchScheduler.instance.start(); + // HAND OFF a Blox pairing return captured at startup (web only; the VM stub + // holder is always empty). Placed HERE because the user must be signed in + // before the credentials are persisted against their session. One-shot: + // `take` clears the holder. + _handoffAutopinReturnIfAny(); + } + + /// If `captureAutopinReturn()` parked FxBlox's return params at startup, + /// move to the pairing screen with them as go_router `extra` (never a + /// query — the secret must not re-enter the address bar). Scheduled after + /// the frame because `_initSignedIn` runs from initState / a listener. + void _handoffAutopinReturnIfAny() { + final params = takePendingAutopinReturn(); + if (params == null) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.go('/blox-pairing', extra: params); + }); } void _onSession() { diff --git a/lib/web/screens/web_settings_screen.dart b/lib/web/screens/web_settings_screen.dart index d790bf9..59a1af2 100644 --- a/lib/web/screens/web_settings_screen.dart +++ b/lib/web/screens/web_settings_screen.dart @@ -272,6 +272,8 @@ class _WebSettingsScreenState extends State { const Divider(height: 1), _integrationsSection(context), const Divider(height: 1), + _devicesSection(context), + const Divider(height: 1), _syncQueueSection(context), const Divider(height: 1), _otherSection(context), @@ -520,6 +522,23 @@ class _WebSettingsScreenState extends State { ); } + // My Devices — pair a Blox (via blox.fx.land) so it auto-pins the user's + // cloud files. Mirrors the native Settings → My Devices entry; the web + // screen is dart:io-free (no mDNS / LAN gateway in a browser). + Widget _devicesSection(BuildContext context) { + return _Section( + label: 'MY DEVICES', + children: [ + ListTile( + leading: const Icon(Icons.storage_outlined), + title: const Text('Blox pairing'), + subtitle: const Text('Pair a Blox to auto-pin your files'), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.push('/blox-pairing'), + ), + ], + ); + } // ── Other (everything else → cloud.fx.land) ────────────────────────── Widget _otherSection(BuildContext context) { return _Section( diff --git a/lib/web/services/web_autopin_return.dart b/lib/web/services/web_autopin_return.dart new file mode 100644 index 0000000..7c18877 --- /dev/null +++ b/lib/web/services/web_autopin_return.dart @@ -0,0 +1,14 @@ +// Platform seam for the WEB-build side of the Blox auto-pin pairing return +// (`docs/AUTOPIN-HANDOFF.md`). +// +// `main_web.dart`, the web router and the web home reach the browser-only +// implementation through this conditional export — never by a direct +// `package:web` import — so the same files stay compilable for `flutter test` +// (VM) via the IO stub. Mirrors `web_hosted_oauth.dart`. +// +// API (identical on both branches): +// captureAutopinReturn() — call in main() BEFORE runApp +// stashPendingAutopinReturn(p) — router fallback / manual park +// takePendingAutopinReturn() — post-login hand-off (read-and-clear) +export 'web_autopin_return_io.dart' + if (dart.library.js_interop) 'web_autopin_return_web.dart'; diff --git a/lib/web/services/web_autopin_return_io.dart b/lib/web/services/web_autopin_return_io.dart new file mode 100644 index 0000000..290be3c --- /dev/null +++ b/lib/web/services/web_autopin_return_io.dart @@ -0,0 +1,23 @@ +// Native (dart:io / VM) stub for the Blox auto-pin return capture. +// +// Exists ONLY so the shared graph compiles for native and under `flutter test` +// (the VM compiles THIS branch of the conditional export, never the +// `package:web` impl). There is no browser URL to read, so CAPTURE is inert; +// the stash/take pair still works against the memory holder so widget tests +// and the router fallback behave the same way on both branches. +// +// Imports nothing web-specific. + +import 'package:fula_files/core/services/blox_pairing_links.dart'; +import 'package:fula_files/web/services/web_autopin_return_logic.dart'; + +/// CAPTURE (native stub): no browser URL — nothing to capture. +void captureAutopinReturn() {} + +/// Park [params] for the post-login hand-off (memory only on the VM). +void stashPendingAutopinReturn(AutopinCompleteParams? params) => + stashPendingAutopinReturnInMemory(params); + +/// Atomic read-and-clear of the memory holder. +AutopinCompleteParams? takePendingAutopinReturn() => + takePendingAutopinReturnFromMemory(); diff --git a/lib/web/services/web_autopin_return_logic.dart b/lib/web/services/web_autopin_return_logic.dart new file mode 100644 index 0000000..51d9ca3 --- /dev/null +++ b/lib/web/services/web_autopin_return_logic.dart @@ -0,0 +1,169 @@ +// PURE (browser-free) logic for the web-build side of the Blox auto-pin +// pairing return (`docs/AUTOPIN-HANDOFF.md`). +// +// This file imports nothing web-specific, so it is the ONLY layer the unit +// tests touch — `flutter test` runs on the Dart VM, which compiles the IO +// stub branch of `web_autopin_return.dart`, never the `package:web` impl. +// +// Flow (mirrors `web_hosted_oauth`): +// CAPTURE — `captureAutopinReturn()` (web impl) runs in `main()` BEFORE +// `runApp`, detects the return in the page location via +// [detectAutopinReturn], stashes the params (memory + +// sessionStorage so a refresh mid-sign-in does not lose them), +// and `history.replaceState`s the URL to [strippedUrl] so the +// secret leaves the address bar / history and the hash router +// boots on a clean `#/`. +// HAND-OFF — the web home's post-login init calls +// `takePendingAutopinReturn()` and navigates to `/blox-pairing` +// with the params as go_router `extra` (never as a query, so the +// secret does not re-enter the URL). +// FALLBACK — if the URL somehow still reaches the router (replaceState +// failed), the `/autopin-complete` route/redirect uses the same +// parser and stash. + +import 'dart:convert'; + +import 'package:fula_files/core/services/blox_pairing_links.dart'; + +/// `window.sessionStorage` key holding the captured return between the +/// capture and the post-login hand-off (per-tab; cleared on take). +const String kAutopinReturnSessionKey = 'fxfiles.autopinReturn.pending'; + +/// The result of inspecting a page location that carries an autopin return. +class AutopinReturnCapture { + const AutopinReturnCapture({required this.params, required this.strippedUrl}); + + /// The parsed (NOT yet validated) parameters. + final AutopinCompleteParams params; + + /// The full URL to `history.replaceState` to: same origin + path, the four + /// return keys removed from the query, and the route fragment reset to `/` + /// when it carried the return. + final String strippedUrl; +} + +/// Inspect a full page [location] (normally `window.location.href`). +/// Returns null when it is not an autopin return (normal startup). +AutopinReturnCapture? detectAutopinReturn(Uri location) { + final params = parseAutopinCompleteParams(location); + if (params == null) return null; + return AutopinReturnCapture( + params: params, + strippedUrl: stripAutopinReturnFromLocation(location), + ); +} + +const Set _returnKeys = { + 'secret', + 'hardwareId', + 'bloxPeerId', + 'bloxName', +}; + +/// Build the cleaned address-bar URL for [location]: +/// - origin + path kept exactly (a `--base-href /app/` subpath survives); +/// - the four return keys dropped from the query, every other query param +/// kept (e.g. the E2E `?e2e=` hooks); +/// - the fragment reset to the home route `/` when it carried the return +/// (hash-route `/autopin-complete?…` or a bare `secret=…` fragment), +/// otherwise kept verbatim. +/// +/// Built by string concatenation (NOT `Uri.replace`, which emits a stray `?` +/// for an empty query), mirroring `stripQueryPreservingFragment`. +String stripAutopinReturnFromLocation(Uri location) { + String base; + try { + base = location.origin + location.path; + } catch (_) { + // Non-http(s) (never the case in a browser) — fall back to a relative URL. + base = location.path; + } + + Map query; + try { + query = Map.of(location.queryParameters); + } catch (_) { + query = {}; + } + query.removeWhere((k, _) => _returnKeys.contains(k)); + final queryString = query.isEmpty + ? '' + : '?${query.entries.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}').join('&')}'; + + final fragment = location.fragment; + final String newFragment; + if (fragment.isEmpty) { + newFragment = ''; + } else if (_fragmentCarriesReturn(fragment)) { + newFragment = '/'; + } else { + newFragment = fragment; + } + + return '$base$queryString${newFragment.isEmpty ? '' : '#$newFragment'}'; +} + +bool _fragmentCarriesReturn(String fragment) { + if (fragment.startsWith('/')) { + final routed = Uri.tryParse(fragment); + return routed != null && + (routed.path == kAutopinReturnPath || + routed.path.endsWith(kAutopinReturnPath)); + } + try { + return Uri.splitQueryString(fragment).containsKey('secret'); + } catch (_) { + return false; + } +} + +// ── Pending holder (memory) ───────────────────────────────────────────────── +// The browser twin mirrors this into sessionStorage; the IO twin uses only the +// memory holder. Kept here so the router fallback and the tests share it. + +AutopinCompleteParams? _pending; + +/// Remember [params] for the post-login hand-off. Null is a no-op. +void stashPendingAutopinReturnInMemory(AutopinCompleteParams? params) { + if (params == null) return; + _pending = params; +} + +/// Atomic read-and-clear of the memory holder. +AutopinCompleteParams? takePendingAutopinReturnFromMemory() { + final p = _pending; + _pending = null; + return p; +} + +/// Non-destructive peek (for tests / guards). +bool hasPendingAutopinReturnInMemory() => _pending != null; + +// ── sessionStorage encoding ───────────────────────────────────────────────── + +/// JSON for `sessionStorage` (only the non-null fields). +String encodeAutopinReturnForSession(AutopinCompleteParams params) => + jsonEncode(params.toQueryParameters()); + +/// Fail-closed decode: null on missing/empty/malformed JSON, a non-object, a +/// non-string value, or a payload that fails [AutopinCompleteParams.validationError]. +AutopinCompleteParams? decodeAutopinReturnFromSession(String? raw) { + if (raw == null || raw.isEmpty) return null; + Object? decoded; + try { + decoded = jsonDecode(raw); + } catch (_) { + return null; + } + if (decoded is! Map) return null; + final m = {}; + for (final entry in decoded.entries) { + final k = entry.key; + final v = entry.value; + if (k is! String || v is! String) return null; + m[k] = v; + } + final params = AutopinCompleteParams.fromMap(m); + if (params == null || !params.isValid) return null; + return params; +} diff --git a/lib/web/services/web_autopin_return_web.dart b/lib/web/services/web_autopin_return_web.dart new file mode 100644 index 0000000..ccc1fb5 --- /dev/null +++ b/lib/web/services/web_autopin_return_web.dart @@ -0,0 +1,86 @@ +// Web (browser) implementation of the Blox auto-pin return capture. Loaded +// ONLY on web via the conditional export in `web_autopin_return.dart`; the +// native/VM build gets `web_autopin_return_io.dart`. +// +// Thin `package:web` shell — every decision (what counts as a return, the +// cleaned URL, the session encoding) lives in the browser-free +// `web_autopin_return_logic.dart`. + +import 'package:flutter/foundation.dart'; +import 'package:web/web.dart' as web; + +import 'package:fula_files/core/services/blox_pairing_links.dart'; +import 'package:fula_files/web/services/web_autopin_return_logic.dart'; + +/// CAPTURE — call as early as possible in `main()` BEFORE `runApp`. +/// +/// Reads the page location (`https://files.fx.land/app/#/autopin-complete?…`, +/// a bare `#secret=…` fragment, or a `?secret=…` query), stashes the params +/// (memory + sessionStorage), and rewrites the address bar via +/// `history.replaceState` so the secret is gone from the URL/history and the +/// hash router boots on `#/`. A no-op on a normal startup — except that a +/// return parked in sessionStorage by an earlier load (refresh mid-sign-in) +/// is restored into memory so the hand-off still happens. +void captureAutopinReturn() { + final location = Uri.parse(web.window.location.href); + final capture = detectAutopinReturn(location); + if (capture == null) { + // Refresh-safety: restore a return parked by a previous load. + final parked = decodeAutopinReturnFromSession(_readSession()); + if (parked != null) stashPendingAutopinReturnInMemory(parked); + return; + } + stashPendingAutopinReturnInMemory(capture.params); + _writeSession(capture.params); + try { + web.window.history.replaceState(null, '', capture.strippedUrl); + } catch (e) { + // Not fatal — the params are already captured; the router's + // /autopin-complete fallback handles the un-stripped URL. + debugPrint('captureAutopinReturn: replaceState failed: $e'); + } +} + +/// Park [params] for the post-login hand-off (router fallback path). +void stashPendingAutopinReturn(AutopinCompleteParams? params) { + if (params == null) return; + stashPendingAutopinReturnInMemory(params); + _writeSession(params); +} + +/// Atomic read-and-clear (memory first, then sessionStorage). Clears BOTH so +/// neither a home re-mount nor a refresh can replay the hand-off. +AutopinCompleteParams? takePendingAutopinReturn() { + final fromMemory = takePendingAutopinReturnFromMemory(); + final fromSession = decodeAutopinReturnFromSession(_readSession()); + _clearSession(); + return fromMemory ?? fromSession; +} + +String? _readSession() { + try { + return web.window.sessionStorage.getItem(kAutopinReturnSessionKey); + } catch (e) { + debugPrint('autopin return: sessionStorage read failed: $e'); + return null; + } +} + +void _writeSession(AutopinCompleteParams params) { + try { + web.window.sessionStorage.setItem( + kAutopinReturnSessionKey, + encodeAutopinReturnForSession(params), + ); + } catch (e) { + debugPrint('autopin return: sessionStorage write failed: $e'); + } +} + +void _clearSession() { + try { + web.window.sessionStorage.removeItem(kAutopinReturnSessionKey); + } catch (e) { + debugPrint('autopin return: sessionStorage clear failed: $e'); + } +} diff --git a/test/unit/web/web_autopin_return_logic_test.dart b/test/unit/web/web_autopin_return_logic_test.dart new file mode 100644 index 0000000..df347ba --- /dev/null +++ b/test/unit/web/web_autopin_return_logic_test.dart @@ -0,0 +1,210 @@ +// PURE (browser-free) logic tests for the web-build Blox auto-pin return. +// +// These exercise ONLY `web_autopin_return_logic.dart` (+ the shared parser in +// `blox_pairing_links.dart`), which import nothing web-specific, so they run +// on the Dart VM under `flutter test`. +// +// Covered: +// 1. detectAutopinReturn — the three URL forms the capture accepts, and the +// no-op on a normal startup / other routes. +// 2. stripAutopinReturnFromLocation — origin + base-href path kept, return +// keys dropped from the query (other params kept), fragment reset to `/` +// only when it carried the return. +// 3. The memory pending holder — stash/take/peek one-shot semantics. +// 4. sessionStorage encode/decode round trip + fail-closed decode +// (malformed JSON, wrong shapes, invalid payloads). + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fula_files/core/services/blox_pairing_links.dart'; +import 'package:fula_files/web/services/web_autopin_return_logic.dart'; + +void main() { + const appBase = 'https://files.fx.land/app/'; + + setUp(() { + // Reset the module-level holder between tests. + takePendingAutopinReturnFromMemory(); + }); + + // =========================================================================== + // 1. detectAutopinReturn + // =========================================================================== + group('detectAutopinReturn', () { + test('hash-route form (what the forwarder\'s "Continue in web app" emits)', + () { + final c = detectAutopinReturn(Uri.parse( + '$appBase#/autopin-complete?secret=s1&hardwareId=hw&bloxPeerId=pid&bloxName=Nm')); + expect(c, isNotNull); + expect(c!.params.secret, 's1'); + expect(c.params.hardwareId, 'hw'); + expect(c.params.bloxPeerId, 'pid'); + expect(c.params.bloxName, 'Nm'); + expect(c.strippedUrl, '$appBase#/'); + }); + + test('bare fragment form (FxBlox returning straight to /app/)', () { + final c = detectAutopinReturn( + Uri.parse('$appBase#secret=s2&hardwareId=hw&bloxPeerId=&bloxName=')); + expect(c, isNotNull); + expect(c!.params.secret, 's2'); + expect(c.params.bloxPeerId, isNull); + expect(c.strippedUrl, '$appBase#/'); + }); + + test('query form', () { + final c = detectAutopinReturn(Uri.parse('$appBase?secret=s3&bloxName=N#/')); + expect(c, isNotNull); + expect(c!.params.secret, 's3'); + expect(c.strippedUrl, '$appBase#/'); + }); + + test('normal startup / other routes → null', () { + expect(detectAutopinReturn(Uri.parse(appBase)), isNull); + expect(detectAutopinReturn(Uri.parse('$appBase#/')), isNull); + expect(detectAutopinReturn(Uri.parse('$appBase#/settings')), isNull); + expect(detectAutopinReturn(Uri.parse('$appBase?e2e=list#/b/documents')), + isNull); + // A `secret` on a DIFFERENT hash route is not an autopin return + // (e.g. the NFT claim route also carries a `secret`). + expect( + detectAutopinReturn(Uri.parse( + '$appBase#/nft-claim?chain=8453&token=1&secret=claimsecret')), + isNull); + }); + + test('an OAuth redirect (?code=…) is not mistaken for a return', () { + expect(detectAutopinReturn(Uri.parse('$appBase?code=abc&state=xyz#/')), + isNull); + }); + }); + + // =========================================================================== + // 2. stripAutopinReturnFromLocation + // =========================================================================== + group('stripAutopinReturnFromLocation', () { + test('keeps origin + base-href path, resets the route fragment', () { + expect( + stripAutopinReturnFromLocation( + Uri.parse('$appBase#/autopin-complete?secret=s')), + '$appBase#/'); + expect( + stripAutopinReturnFromLocation( + Uri.parse('https://files.fx.land/#/autopin-complete?secret=s')), + 'https://files.fx.land/#/'); + }); + + test('drops only the four return keys from the query, keeps the rest', () { + final out = stripAutopinReturnFromLocation(Uri.parse( + '$appBase?e2e=list&secret=s&hardwareId=h&bloxPeerId=p&bloxName=n&seed=w1#/')); + expect(out, '$appBase?e2e=list&seed=w1#/'); + }); + + test('a fragment that is NOT the return is kept verbatim', () { + expect( + stripAutopinReturnFromLocation( + Uri.parse('$appBase?secret=s#/b/documents?open=k')), + '$appBase#/b/documents?open=k'); + }); + + test('bare secret fragment is replaced by the home route', () { + expect( + stripAutopinReturnFromLocation(Uri.parse('$appBase#secret=s&bloxName=n')), + '$appBase#/'); + }); + + test('no fragment at all stays without one', () { + expect(stripAutopinReturnFromLocation(Uri.parse('$appBase?secret=s')), + appBase); + }); + + test('never emits a stray "?" for an emptied query', () { + final out = stripAutopinReturnFromLocation( + Uri.parse('$appBase?secret=s#/autopin-complete?secret=s')); + expect(out, isNot(contains('?#'))); + expect(out, '$appBase#/'); + }); + }); + + // =========================================================================== + // 3. Memory pending holder + // =========================================================================== + group('pending holder (memory)', () { + test('take is one-shot and null when empty', () { + expect(hasPendingAutopinReturnInMemory(), isFalse); + expect(takePendingAutopinReturnFromMemory(), isNull); + stashPendingAutopinReturnInMemory( + const AutopinCompleteParams(secret: 'abc')); + expect(hasPendingAutopinReturnInMemory(), isTrue); + expect(takePendingAutopinReturnFromMemory()?.secret, 'abc'); + expect(hasPendingAutopinReturnInMemory(), isFalse); + expect(takePendingAutopinReturnFromMemory(), isNull); + }); + + test('stashing null is a no-op (does not clear an existing value)', () { + stashPendingAutopinReturnInMemory( + const AutopinCompleteParams(secret: 'keep')); + stashPendingAutopinReturnInMemory(null); + expect(takePendingAutopinReturnFromMemory()?.secret, 'keep'); + }); + + test('a later stash overwrites the earlier one', () { + stashPendingAutopinReturnInMemory( + const AutopinCompleteParams(secret: 'first')); + stashPendingAutopinReturnInMemory( + const AutopinCompleteParams(secret: 'second')); + expect(takePendingAutopinReturnFromMemory()?.secret, 'second'); + }); + }); + + // =========================================================================== + // 4. sessionStorage encoding + // =========================================================================== + group('session encode/decode', () { + test('round-trips every field', () { + const p = AutopinCompleteParams( + secret: 's', + hardwareId: 'h', + bloxPeerId: 'p', + bloxName: 'Café Blox', + ); + final back = decodeAutopinReturnFromSession( + encodeAutopinReturnForSession(p)); + expect(back, isNotNull); + expect(back!.secret, 's'); + expect(back.hardwareId, 'h'); + expect(back.bloxPeerId, 'p'); + expect(back.bloxName, 'Café Blox'); + }); + + test('round-trips with optional fields absent', () { + const p = AutopinCompleteParams(secret: 'only'); + final back = decodeAutopinReturnFromSession( + encodeAutopinReturnForSession(p)); + expect(back!.secret, 'only'); + expect(back.hardwareId, isNull); + expect(back.bloxPeerId, isNull); + expect(back.bloxName, isNull); + }); + + test('fails closed on missing / malformed / wrong-shape input', () { + expect(decodeAutopinReturnFromSession(null), isNull); + expect(decodeAutopinReturnFromSession(''), isNull); + expect(decodeAutopinReturnFromSession('not json'), isNull); + expect(decodeAutopinReturnFromSession('[1,2]'), isNull); + expect(decodeAutopinReturnFromSession('{"secret": 1}'), isNull); + expect(decodeAutopinReturnFromSession('{"secret": ""}'), isNull); + expect(decodeAutopinReturnFromSession('{"hardwareId": "h"}'), isNull); + }); + + test('fails closed on a payload that fails validation', () { + final tooLong = '{"secret": "${'a' * 600}"}'; + expect(decodeAutopinReturnFromSession(tooLong), isNull); + expect(decodeAutopinReturnFromSession('{"secret": "a\\nb"}'), isNull); + }); + + test('the session key is namespaced', () { + expect(kAutopinReturnSessionKey, startsWith('fxfiles.')); + }); + }); +} From 5f76aaa3f6e7d0e50707cc319aea661e55c9ed90 Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Thu, 27 Aug 2026 09:56:08 -0400 Subject: [PATCH 3/5] feat(site): /autopin-complete forwarder + hand-off docs site/autopin-complete/index.html (cloned from nft-claim): reads secret/hardwareId/bloxPeerId/bloxName from the fragment (query fallback); on mobile UA auto-tries fxfiles://autopin-complete?... with the 2.5 s document.hidden fallback (store links only, never a second navigation); otherwise "Open in FxFiles" + "Continue in web app" -> https://files.fx.land/app/#/autopin-complete?... (params stay in the hash, client-side). Docs: docs/AUTOPIN-HANDOFF.md (copy of the v1 contract), architecture.md section 2 (entry points + URL schemes incl. the web hand-off and the web LAN limitation), README web section. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01QyQw3WtVXDTtvJKN7ykczw --- README.md | 14 +- architecture.md | 38 ++++- docs/AUTOPIN-HANDOFF.md | 30 ++++ site/autopin-complete/index.html | 284 +++++++++++++++++++++++++++++++ 4 files changed, 360 insertions(+), 6 deletions(-) create mode 100644 docs/AUTOPIN-HANDOFF.md create mode 100644 site/autopin-complete/index.html diff --git a/README.md b/README.md index e3cac83..63acd84 100644 --- a/README.md +++ b/README.md @@ -816,8 +816,18 @@ design — the web compile graph never imports them. - Deploys: any push to `main` touching `lib/`, `web/`, `site/` or pubspec redeploys GitHub Pages (`.github/workflows/deploy-pages.yml`). The artifact keeps `site/` byte-identical at the domain root (the - `/nft-claim` forwarder and `.well-known` app-link files are - load-bearing for native deep links) and serves the app under `/app/`. + `/nft-claim` and `/autopin-complete` forwarders and `.well-known` + app-link files are load-bearing for native deep links) and serves + the app under `/app/`. +- Blox pairing from the web: Settings → My Devices → Pair Blox hands + off to https://blox.fx.land (contract: `docs/AUTOPIN-HANDOFF.md`), + which returns to `files.fx.land/autopin-complete#secret=…` → + "Continue in web app". Known limitation: the web app cannot use the + Blox LAN gateway (`http://` is mixed content and browsers + have no mDNS), so web downloads always come from the cloud; pairing + still makes the Blox auto-pin your files. The stored credentials are + local to that browser — pair native/desktop FxFiles separately + (desktop's manual dialog accepts the pairing secret). - E2E harness: build with `--dart-define=E2E=true`, then drive headless Chrome with `?e2e=create|signin|restore|signout|upload|` `download|list|delete|share` (results print with an `[e2e]` prefix). diff --git a/architecture.md b/architecture.md index fafff64..f68fe74 100644 --- a/architecture.md +++ b/architecture.md @@ -91,13 +91,43 @@ FxFiles app ### Additional UX — Pairing +Contract: `docs/AUTOPIN-HANDOFF.md` (v1). URL builders + the return parser live in +`lib/core/services/blox_pairing_links.dart` (dart:io-free; unit-tested). + 1. User goes to **Settings → My Devices → Pair Blox** -2. App opens deeplink to **FxBlox companion app**, passing JWT -3. FxBlox app calls blox's `AutoPinPair(token, endpoint)` via libp2p +2. FxFiles hands off to FxBlox with the SAME params on one of two carriers: + - **App** (mobile): `fxblox://autopin-pair?token=&endpoint=&returnUrl=