diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 67aadcd..355aa42 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -80,6 +80,12 @@ + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/erebrus/erebrus_drop/MainActivity.kt b/android/app/src/main/kotlin/com/erebrus/erebrus_drop/MainActivity.kt index 8b3e41d..899f4f7 100644 --- a/android/app/src/main/kotlin/com/erebrus/erebrus_drop/MainActivity.kt +++ b/android/app/src/main/kotlin/com/erebrus/erebrus_drop/MainActivity.kt @@ -113,6 +113,22 @@ class MainActivity : FlutterActivity() { result = result, ) } + "signMessage" -> { + val packageName = call.argument("packageName") + val authToken = call.argument("authToken") + val message = call.argument("message") + if (packageName.isNullOrBlank() || authToken.isNullOrBlank() || message.isNullOrBlank()) { + result.error("INVALID_ARGS", "packageName, authToken and message are required.", null) + return@setMethodCallHandler + } + SolanaWalletBridge.signMessage( + activity = this, + packageName = packageName, + authToken = authToken, + message = message, + result = result, + ) + } else -> result.notImplemented() } } diff --git a/android/app/src/main/kotlin/com/erebrus/erebrus_drop/SolanaWalletBridge.kt b/android/app/src/main/kotlin/com/erebrus/erebrus_drop/SolanaWalletBridge.kt index e3443ed..4d9be26 100644 --- a/android/app/src/main/kotlin/com/erebrus/erebrus_drop/SolanaWalletBridge.kt +++ b/android/app/src/main/kotlin/com/erebrus/erebrus_drop/SolanaWalletBridge.kt @@ -185,6 +185,101 @@ object SolanaWalletBridge { } } + fun signMessage( + activity: Activity, + packageName: String, + authToken: String, + message: String, + result: MethodChannel.Result, + ) { + if (pendingResult != null) { + result.error("BUSY", "Wallet connection already in progress.", null) + return + } + if (packageName.isBlank() || authToken.isBlank() || message.isBlank()) { + result.error("INVALID_ARGS", "packageName, authToken and message are required.", null) + return + } + + pendingResult = result + val scenario = LocalAssociationScenario(Scenario.DEFAULT_CLIENT_TIMEOUT_MS) + pendingScenario = scenario + val packageManager = activity.packageManager + val wallet = knownWalletFor(packageName) + val associationIntent = if (wallet != null && canHandleMwaAssociation(packageManager, wallet)) { + buildAssociationIntent(packageManager, wallet, scenario) + } else { + null + } + + timeoutRunnable = Runnable { + failPending("Wallet sign-in timed out after 30 seconds. Try again.") + } + mainHandler.postDelayed(timeoutRunnable!!, CONNECT_TIMEOUT_MS) + + executor.execute { + try { + if (associationIntent != null) { + val launchLatch = java.util.concurrent.CountDownLatch(1) + var launchError: Throwable? = null + mainHandler.post { + try { + activity.startActivityForResult(associationIntent, WALLET_REQUEST_CODE) + } catch (error: Throwable) { + launchError = error + } finally { + launchLatch.countDown() + } + } + launchLatch.await() + if (launchError != null) { + throw launchError!! + } + } + + val client = scenario.start().get() + val authResult = client.reauthorize( + MWA_IDENTITY_URI, + MWA_ICON_RELATIVE_URI, + "Erebrus Drop", + authToken, + ).get() + + val messageBytes = message.toByteArray(Charsets.UTF_8) + val signed = client.signMessages( + listOf(authResult.publicKey), + listOf(messageBytes), + ).get() + + if (signed.signedMessages.isEmpty() || signed.signedMessages[0].signatures.isEmpty()) { + throw IllegalStateException("Wallet did not return a signature.") + } + + val signatureBytes = signed.signedMessages[0].signatures[0] + val signatureHex = signatureBytes.joinToString("") { "%02x".format(it) } + + clearTimeout() + mainHandler.post { + completePending( + mapOf( + "publicKey" to authResult.publicKey, + "authToken" to authResult.authToken, + "signature" to signatureHex, + ), + ) + scenario.close() + pendingScenario = null + } + } catch (error: Throwable) { + mainHandler.post { + failPending(error.message ?: "Wallet sign-in failed.") + scenario.close() + pendingScenario = null + } + } + } + } + fun deauthorizeWallet( activity: Activity, packageName: String, diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index bb3bfe1..5ddf99c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -91,5 +91,16 @@ UIViewControllerBasedStatusBarAppearance + CFBundleURLTypes + + + CFBundleURLName + com.erebrus.drop.auth + CFBundleURLSchemes + + erebrusdrop + + + diff --git a/lib/app.dart b/lib/app.dart index 4e367e1..24891ba 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -12,6 +12,10 @@ import 'core/drop_models.dart'; import 'core/host_folder_bridge.dart'; import 'core/platform_capabilities.dart'; import 'core/platform_network.dart'; +import 'features/gateway/drop_auth_service.dart'; +import 'features/gateway/gateway_models.dart'; +import 'features/gateway/gateway_sheets.dart'; +import 'features/gateway/login_screen.dart'; import 'features/host/host_folder_service.dart'; import 'features/host/room_runtime_service.dart'; import 'features/join/join_room_service.dart'; @@ -123,8 +127,22 @@ class _DropHomeScreenState extends State NativeFilePickerService(); final HostFolderBridge _hostFolderBridge = HostFolderBridge(); final SolanaWalletService _solanaWalletService = SolanaWalletService(); + late final DropAuthService _dropAuthService = + DropAuthService(solana: _solanaWalletService); bool _isSolanaMobileDevice = false; final ValueNotifier _networkUiVersion = ValueNotifier(0); + + // Gateway / org state + List _gatewayNodes = const []; + List _gatewayFiles = const []; + bool _gatewayNodesLoading = false; + bool _gatewayFilesLoading = false; + String? _gatewayError; + DropNode? _selectedSendNode; + bool _gatewayUploading = false; + String? _gatewayUploadedCid; + int _libraryScopeIndex = 0; // 0 Local, 1 Global + int _smartSendScopeIndex = 0; // 0 Local, 1 Global final ValueNotifier _joinUiVersion = ValueNotifier(0); final TextEditingController _roomName = TextEditingController( text: _defaultRoomName(), @@ -196,6 +214,8 @@ class _DropHomeScreenState extends State unawaited(_loadHostFolderSelection()); unawaited(_refreshNetworkStatus()); unawaited(_detectSolanaMobileDevice()); + unawaited(_loadGatewaySession()); + _dropAuthService.selectedOrg.addListener(_onSelectedOrgChanged); _shareSubscription = _shareIntakeService.watchIncomingShares().listen( (payload) => unawaited(_handleSharedPayload(payload)), ); @@ -240,6 +260,103 @@ class _DropHomeScreenState extends State setState(() => _isSolanaMobileDevice = isSolanaDevice); } + Future _loadGatewaySession() async { + await _dropAuthService.loadSession(); + await _refreshGatewayNodes(); + if (mounted) { + await _refreshGatewayFiles(); + } + } + + void _onSelectedOrgChanged() { + if (!_dropAuthService.isSignedIn || !mounted) return; + unawaited(_refreshGatewayNodes()); + unawaited(_refreshGatewayFiles()); + } + + Future _refreshGatewayNodes() async { + if (!_dropAuthService.isSignedIn || _gatewayNodesLoading) return; + if (mounted) setState(() => _gatewayNodesLoading = true); + _gatewayError = null; + try { + final public = await _dropAuthService.gatewayClient.fetchDropNodes( + scope: 'public', + ); + final org = _dropAuthService.selectedOrg.value; + final private = org != null + ? await _dropAuthService.gatewayClient.fetchDropNodes( + scope: 'private', + orgId: org.id, + ) + : const []; + final seen = {}; + final nodes = [ + ...public, + ...private, + ].where((n) { + if (!n.online) return false; + if (seen.contains(n.nodeId)) return false; + return seen.add(n.nodeId); + }).toList(); + if (mounted) { + setState(() { + _gatewayNodes = nodes; + if (_selectedSendNode == null && nodes.isNotEmpty) { + _selectedSendNode = nodes.firstWhere( + (n) => n.acceptingUploads, + orElse: () => nodes.first, + ); + } + _gatewayError = null; + }); + } + } catch (e) { + if (mounted) setState(() => _gatewayError = e.toString()); + } finally { + if (mounted) setState(() => _gatewayNodesLoading = false); + } + } + + Future _showGatewayLogin() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => GatewayLoginScreen(auth: _dropAuthService), + ), + ); + if (!mounted) return; + if (_dropAuthService.isSignedIn) { + _snack('Signed in to Erebrus gateway'); + await _refreshGatewayNodes(); + if (mounted) await _refreshGatewayFiles(); + } + } + + Future _refreshGatewayFiles() async { + if (!_dropAuthService.isSignedIn || _gatewayFilesLoading) return; + if (mounted) setState(() => _gatewayFilesLoading = true); + try { + final myFiles = await _dropAuthService.gatewayClient.fetchMyFiles(); + final org = _dropAuthService.selectedOrg.value; + final orgFiles = org != null + ? await _dropAuthService.gatewayClient.fetchOrgFiles(org.id) + : const []; + final seen = {}; + final files = [ + ...myFiles, + ...orgFiles, + ].where((f) { + if (seen.contains(f.id)) return false; + return seen.add(f.id); + }).toList(); + files.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + if (mounted) setState(() => _gatewayFiles = files); + } catch (e) { + if (mounted) setState(() => _gatewayError = e.toString()); + } finally { + if (mounted) setState(() => _gatewayFilesLoading = false); + } + } + Future _loadDeviceName() async { if (_server.isRunning) return; final fallback = Platform.localHostname; @@ -308,6 +425,7 @@ class _DropHomeScreenState extends State void dispose() { WidgetsBinding.instance.removeObserver(this); _refreshTimer?.cancel(); + _dropAuthService.selectedOrg.removeListener(_onSelectedOrgChanged); _roomName.dispose(); _deviceName.dispose(); _password.dispose(); @@ -341,6 +459,7 @@ class _DropHomeScreenState extends State } unawaited(_loadLibraryFiles()); unawaited(_refreshNetworkStatus()); + unawaited(_refreshGatewayFiles()); unawaited( _shareIntakeService.consumeInitialShare().then((payload) { if (payload != null) { @@ -358,6 +477,12 @@ class _DropHomeScreenState extends State } if (index == 2) { unawaited(_loadLibraryFiles()); + if (_libraryScopeIndex == 1) { + unawaited(_refreshGatewayFiles()); + } + } + if (index == 3 && _smartSendScopeIndex == 1) { + unawaited(_refreshGatewayNodes()); } } @@ -678,18 +803,33 @@ class _DropHomeScreenState extends State children: [ _Head( title: 'Library', - subtitle: 'Shared this session', + subtitle: _libraryScopeIndex == 0 ? 'Shared this session' : 'Files pinned to erebrus nodes', action: DropIconButton( icon: Icons.refresh_rounded, - busy: _loadingLibraryFiles, + busy: _loadingLibraryFiles || _gatewayFilesLoading, tooltip: 'Refresh', - onPressed: hasLibrarySource - ? () => unawaited(_loadLibraryFiles()) - : null, + onPressed: () { + if (_libraryScopeIndex == 0) { + if (hasLibrarySource) unawaited(_loadLibraryFiles()); + } else { + unawaited(_refreshGatewayFiles()); + } + }, ), ), + const SizedBox(height: 12), + _scopeToggle( + labels: const ['Local', 'Global'], + selected: _libraryScopeIndex, + onSelected: (i) { + setState(() => _libraryScopeIndex = i); + if (i == 1) unawaited(_refreshGatewayFiles()); + }, + ), const SizedBox(height: 16), - if (!hasLibrarySource) + if (_libraryScopeIndex == 1) ...[ + _gatewayLibraryPanel(), + ] else if (!hasLibrarySource) _InfoCard( title: 'Choose a Drop folder', subtitle: @@ -727,6 +867,128 @@ class _DropHomeScreenState extends State ); } + Widget _scopeToggle({ + required List labels, + required int selected, + required ValueChanged onSelected, + }) { + return ToggleButtons( + isSelected: labels.map((_) => false).toList() + ..[selected] = true, + onPressed: (index) => onSelected(index), + borderRadius: BorderRadius.circular(12), + borderColor: DropTheme.line, + selectedBorderColor: DropTheme.orange, + fillColor: DropTheme.orange.withValues(alpha: 0.18), + selectedColor: DropTheme.orange, + color: DropTheme.muted, + constraints: const BoxConstraints(minHeight: 40, minWidth: 80), + children: labels + .map((label) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text(label), + )) + .toList(), + ); + } + + Widget _gatewayLibraryPanel() { + if (!_dropAuthService.isSignedIn) { + return _InfoCard( + title: 'Sign in to view global files', + subtitle: 'Connect your wallet to see files pinned to public and organization nodes.', + icon: Icons.cloud_outlined, + onTap: () => unawaited(_showGatewayLogin()), + ); + } + if (_gatewayFilesLoading && _gatewayFiles.isEmpty) { + return const _InfoCard( + title: 'Loading gateway files', + subtitle: 'Fetching files from public and organization nodes.', + icon: Icons.cloud_sync_outlined, + ); + } + if (_gatewayFiles.isEmpty) { + return _InfoCard( + title: 'No global files yet', + subtitle: _dropAuthService.selectedOrg.value == null + ? 'Switch to a selected organization in Settings to see its pinned files.' + : 'Files pinned to public or ${(_dropAuthService.selectedOrg.value?.name ?? 'organization')} nodes appear here.', + icon: Icons.cloud_off_outlined, + onTap: () => unawaited(_refreshGatewayFiles()), + ); + } + return DropCard( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < _gatewayFiles.length; i++) + _gatewayFileTile(_gatewayFiles[i], first: i == 0), + ], + ), + ); + } + + Widget _gatewayFileTile(DropGatewayFile file, {required bool first}) { + final (color, icon) = _fileTypeStyle(DropFileItem( + id: file.id, + name: file.filename, + type: file.contentType ?? 'file', + path: file.cid ?? '', + sizeBytes: file.sizeBytes, + createdAt: file.createdAt, + modifiedAt: file.createdAt, + mimeType: file.contentType, + streamable: false, + )); + final scope = file.scope; + final scopeLabel = file.orgId != null ? '$scope · org' : scope; + final meta = '${formatBytes(file.sizeBytes)} · ${_shortWhen(file.createdAt)} · $scopeLabel'; + return DecoratedBox( + decoration: BoxDecoration( + border: first ? null : const Border(top: BorderSide(color: DropTheme.line)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), + child: Row( + children: [ + LeadingTile(icon: icon, accent: color, size: 42), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + file.filename, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 3), + Text( + meta, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + if (file.cid?.isNotEmpty == true) + IconButton( + onPressed: () => _copy(file.cid!, 'IPFS CID copied'), + icon: const Icon(Icons.copy_rounded, size: 19), + color: DropTheme.faint, + tooltip: 'Copy CID', + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + ); + } + Widget _libraryFilesCard() { return DropCard( padding: EdgeInsets.zero, @@ -781,8 +1043,6 @@ class _DropHomeScreenState extends State } Widget _smartSendTab() { - final hosting = _server.isRunning; - final canSaveSmartText = hosting || _hostFolderSelection != null; return _Screen( glowAlignment: Alignment.topLeft, child: Column( @@ -790,71 +1050,226 @@ class _DropHomeScreenState extends State children: [ _Head( title: 'Smart Send', - subtitle: 'Push text into the room', - action: DropIconButton( - icon: Icons.content_paste_rounded, - tonal: true, - tooltip: 'Paste clipboard', - onPressed: _pasteClipboard, - ), + subtitle: _smartSendScopeIndex == 0 + ? 'Push text into the room' + : 'Upload files to erebrus nodes and get the share link', + action: _smartSendScopeIndex == 0 + ? DropIconButton( + icon: Icons.content_paste_rounded, + tonal: true, + tooltip: 'Paste clipboard', + onPressed: _pasteClipboard, + ) + : null, ), - const SizedBox(height: 16), - _smartDestinationCard(), const SizedBox(height: 12), - DropCard( - child: Column( - children: [ - TextField( - controller: _smartTitle, - decoration: const InputDecoration(labelText: 'Title'), + _scopeToggle( + labels: const ['Local', 'Global'], + selected: _smartSendScopeIndex, + onSelected: (i) { + setState(() => _smartSendScopeIndex = i); + if (i == 1) unawaited(_refreshGatewayNodes()); + }, + ), + const SizedBox(height: 16), + if (_smartSendScopeIndex == 1) + _gatewaySendPanel() + else + _localSmartSendBody(), + ], + ), + ); + } + + Widget _localSmartSendBody() { + final hosting = _server.isRunning; + final canSaveSmartText = hosting || _hostFolderSelection != null; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _smartDestinationCard(), + const SizedBox(height: 12), + DropCard( + child: Column( + children: [ + TextField( + controller: _smartTitle, + decoration: const InputDecoration(labelText: 'Title'), + ), + const SizedBox(height: 12), + TextField( + controller: _smartText, + minLines: 7, + maxLines: 13, + decoration: const InputDecoration( + labelText: 'Text, link, SMS copy, or note', + alignLabelWithHint: true, ), - const SizedBox(height: 12), - TextField( - controller: _smartText, - minLines: 7, - maxLines: 13, - decoration: const InputDecoration( - labelText: 'Text, link, SMS copy, or note', - alignLabelWithHint: true, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: PrimaryButton( + label: hosting ? 'Send to Room' : 'Save to Folder', + icon: Icons.send_rounded, + onPressed: canSaveSmartText ? _saveSmartText : null, + ), ), - ), - const SizedBox(height: 12), + const SizedBox(width: 10), + DropIconButton( + icon: Icons.clear_rounded, + tooltip: 'Clear', + onPressed: () { + _smartText.clear(); + _smartTitle.text = 'Quick text'; + }, + ), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + const _FeatureGrid( + items: [ + ('Share Sheet', Icons.ios_share_rounded, 'From any app'), + ('Files', Icons.attach_file_rounded, 'Native picker'), + ('Links', Icons.link_rounded, 'Send as text'), + ], + ), + ], + ); + } + + Widget _gatewaySendPanel() { + if (!_dropAuthService.isSignedIn) { + return _InfoCard( + title: 'Sign in to send to gateway nodes', + subtitle: 'Connect your wallet to access public and organization Drop nodes.', + icon: Icons.cloud_outlined, + onTap: () => unawaited(_showGatewayLogin()), + ); + } + if (_gatewayNodes.isEmpty && !_gatewayNodesLoading) { + return _InfoCard( + title: 'No gateway nodes available', + subtitle: _gatewayError ?? 'No public or organization nodes are online right now.', + icon: Icons.cloud_off_outlined, + onTap: () => unawaited(_refreshGatewayNodes()), + ); + } + + final selected = _selectedSendNode; + final nodes = _gatewayNodes; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Target node', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 10), + DropdownButton( + isExpanded: true, + value: selected != null && nodes.any((n) => n.nodeId == selected.nodeId) + ? selected + : null, + hint: const Text('Select a node'), + items: nodes + .map((node) => DropdownMenuItem( + value: node, + child: Text( + '${node.name} · ${node.region.isEmpty ? 'global' : node.region}', + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (node) => setState(() => _selectedSendNode = node), + ), + ], + ), + ), + const SizedBox(height: 12), + if (_gatewayUploadedCid?.isNotEmpty == true) + DropCard.tinted( + accent: DropTheme.success, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Eyebrow('IPFS CID', color: DropTheme.success), + const SizedBox(height: 8), Row( children: [ Expanded( - child: PrimaryButton( - label: hosting ? 'Send to Room' : 'Save to Folder', - icon: Icons.send_rounded, - onPressed: canSaveSmartText ? _saveSmartText : null, + child: MonoText( + _gatewayUploadedCid!, + size: 13, + color: DropTheme.white, ), ), - const SizedBox(width: 10), - DropIconButton( - icon: Icons.clear_rounded, - tooltip: 'Clear', - onPressed: () { - _smartText.clear(); - _smartTitle.text = 'Quick text'; - }, + IconButton( + onPressed: () => _copy(_gatewayUploadedCid!, 'CID copied'), + icon: const Icon(Icons.copy_rounded, size: 20), + color: DropTheme.success, ), ], ), ], ), ), - const SizedBox(height: 12), - const _FeatureGrid( - items: [ - ('Share Sheet', Icons.ios_share_rounded, 'From any app'), - ('Files', Icons.attach_file_rounded, 'Native picker'), - ('Links', Icons.link_rounded, 'Send as text'), - ], - ), - ], - ), + if (_gatewayUploadedCid?.isNotEmpty == true) const SizedBox(height: 12), + PrimaryButton( + label: _gatewayUploading ? 'Uploading…' : 'Choose file & upload', + icon: Icons.cloud_upload_rounded, + busy: _gatewayUploading, + onPressed: selected != null && !_gatewayUploading + ? () => unawaited(_uploadToGatewayNode()) + : null, + ), + ], ); } + Future _uploadToGatewayNode() async { + final node = _selectedSendNode; + final org = _dropAuthService.selectedOrg.value; + if (node == null) return; + final picked = await _nativeFilePickerService.pickFileForUpload(); + if (picked == null || picked.path.isEmpty) return; + if (!mounted) return; + setState(() { + _gatewayUploading = true; + _gatewayUploadedCid = null; + }); + try { + final file = File(picked.path); + final isOrgNode = org != null && node.orgId == org.id; + final orgId = isOrgNode ? org.id : null; + final uploaded = await _dropAuthService.gatewayClient.uploadFile( + nodeId: node.nodeId, + file: file, + filename: picked.name, + visibility: 'public', + scope: isOrgNode ? 'private' : 'public', + orgId: orgId, + ); + if (!mounted) return; + setState(() => _gatewayUploadedCid = uploaded.cid); + _snack('File pinned to gateway node'); + unawaited(_refreshGatewayFiles()); + } catch (e) { + if (!mounted) return; + _snack('Gateway upload failed: $e'); + } finally { + if (mounted) setState(() => _gatewayUploading = false); + } + } + Widget _smartDestinationCard() { final hosting = _server.isRunning; final hasFolder = _hostFolderSelection != null; @@ -935,6 +1350,8 @@ class _DropHomeScreenState extends State SolanaWalletCard(walletService: _solanaWalletService), ], const SizedBox(height: 16), + _gatewayAccountCard(), + const SizedBox(height: 12), DropCard( padding: EdgeInsets.zero, child: Column( @@ -1014,6 +1431,63 @@ class _DropHomeScreenState extends State ); } + Widget _gatewayAccountCard() { + final signedIn = _dropAuthService.isSignedIn; + final org = _dropAuthService.selectedOrg.value; + final wallet = _dropAuthService.walletAddress; + final label = signedIn + ? (org?.name ?? 'Personal') + : 'Gateway account'; + final sub = signedIn + ? '${wallet != null && wallet.length > 12 ? '${wallet.substring(0, 6)}…${wallet.substring(wallet.length - 4)}' : 'Unknown wallet'} · ${org?.plan ?? 'Free'}' + : 'Sign in to access public and organization Drop nodes.'; + return DropCard( + child: Row( + children: [ + LeadingTile( + icon: signedIn ? Icons.cloud_done_rounded : Icons.cloud_off_rounded, + accent: signedIn ? DropTheme.success : DropTheme.muted, + size: 42, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 2), + Text(sub, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + const SizedBox(width: 8), + if (signedIn && org != null) + TonalButton( + label: 'Switch', + onPressed: () => unawaited(_showOrgSwitcher()), + ) + else if (!signedIn) + TonalButton( + label: 'Sign in', + onPressed: () => unawaited(_showGatewayLogin()), + ), + ], + ), + ); + } + + Future _showOrgSwitcher() async { + final result = await showGatewayOrgSheet( + context: context, + authService: _dropAuthService, + ); + if (result == GatewayOrgSheetResult.changed && mounted) { + _snack('Organization updated'); + unawaited(_refreshGatewayNodes()); + unawaited(_refreshGatewayFiles()); + } + } + Widget _hostFolderSettingsCard() { final selection = _hostFolderSelection; final pathLabel = selection == null diff --git a/lib/core/platform_wallet.dart b/lib/core/platform_wallet.dart index 862ec99..b504c8f 100644 --- a/lib/core/platform_wallet.dart +++ b/lib/core/platform_wallet.dart @@ -92,4 +92,26 @@ class PlatformWallet { }, ); } + + static Future signMessage({ + required SolanaWalletOption wallet, + required String authToken, + required String message, + }) async { + final result = await _channel.invokeMapMethod( + 'signMessage', + { + 'packageName': wallet.packageName, + 'authToken': authToken, + 'message': message, + }, + ); + if (result == null || result['signature'] is! String) { + throw PlatformException( + code: 'SIGN_MESSAGE_FAILED', + message: 'Wallet did not return a signature.', + ); + } + return result['signature'] as String; + } } \ No newline at end of file diff --git a/lib/features/gateway/auth_config.dart b/lib/features/gateway/auth_config.dart new file mode 100644 index 0000000..cabf98d --- /dev/null +++ b/lib/features/gateway/auth_config.dart @@ -0,0 +1,85 @@ +import 'dart:io'; + +/// Reown (WalletConnect) project id — Android / iOS wallet login. +const kReownProjectId = String.fromEnvironment('REOWN_PROJECT_ID'); + +/// True when [kReownProjectId] was passed via --dart-define / .env. +bool get hasReownProjectId => kReownProjectId.isNotEmpty; + +/// Google Sign-In server (web) client id. Its audience must be listed in the +/// gateway's GOOGLE_CLIENT_IDS. Empty => Google sign-in is hidden. +const kGoogleServerClientId = String.fromEnvironment( + 'GOOGLE_SERVER_CLIENT_ID', + defaultValue: + '743089346496-15iub9ug9b4jkqonokg2js80ndjv8nba.apps.googleusercontent.com', +); +bool get hasGoogleSignIn => kGoogleServerClientId.isNotEmpty; + +/// Apple Sign-In Services id + redirect, needed only for the web/Android relay +/// flow; on iOS/macOS native Apple sign-in uses the app's capability instead. +const kAppleServiceId = String.fromEnvironment('APPLE_SERVICE_ID'); +const kAppleRedirectUri = String.fromEnvironment( + 'APPLE_REDIRECT_URI', + defaultValue: 'https://gateway.erebrus.io/api/v2/auth/apple/callback', +); + +/// Erebrus webapp origin. +const kErebrusWebOrigin = String.fromEnvironment( + 'EREBRUS_WEB_ORIGIN', + defaultValue: 'https://erebrus.io', +); +const kErebrusProductionOrigin = 'https://erebrus.io'; + +/// Webapp route the browser opens for sign-in. +const kErebrusDesktopAuthPath = '/auth'; + +/// Native deep link the webapp redirects to after auth. +const kErebrusAuthCallbackScheme = 'erebrusdrop'; +const kErebrusAuthCallbackHost = 'auth'; +const kErebrusAuthCallback = 'erebrusdrop://auth'; + +/// Gateway chain identifier for Solana wallet login. +const kSolanaChain = 'sol'; + +/// App path on the erebrus site for icons and MWA identity. +const kErebrusDropBasePath = '/drop'; +const kErebrusDropLogoFile = 'logo.png'; +const kErebrusDropLogoPath = '$kErebrusDropBasePath/$kErebrusDropLogoFile'; + +String _erebrusOriginBase(String webOrigin) => + webOrigin.replaceAll(RegExp(r'/+$'), ''); + +/// Trailing-slash site URL for WalletConnect / Reown metadata. +String erebrusSiteUrlFromOrigin(String webOrigin) => + '${_erebrusOriginBase(webOrigin)}$kErebrusDropBasePath/'; + +/// Publicly reachable icon for Reown / WalletConnect pairing metadata. +String erebrusSiteIconFromOrigin(String webOrigin) => + '${_erebrusOriginBase(webOrigin)}$kErebrusDropLogoPath'; + +/// MWA identity URI base. +String erebrusMwaIdentityUrlFromOrigin(String webOrigin) => + '${_erebrusOriginBase(webOrigin)}$kErebrusDropBasePath/'; + +const kErebrusMwaIconRelative = kErebrusDropLogoFile; + +const kErebrusNativeRedirect = 'erebrusdrop://'; +const kErebrusUniversalRedirect = 'https://erebrus.io/drop'; + +const kReownProjectIdMissingMessage = + 'REOWN_PROJECT_ID is not set. Add it via --dart-define=REOWN_PROJECT_ID=... ' + 'or in a .env file, then rebuild the app.'; + +String reownOriginNotAllowedMessage(String relayOrigin) => + 'Reown relay rejected this app (origin not allowed). In cloud.reown.com → ' + 'your project → Allowlist, add: $relayOrigin and https://erebrus.io/drop — then ' + 'wait ~15 minutes and restart the app.'; + +const kErebrusBundleId = 'com.erebrus.drop'; +const kErebrusLinuxApplicationId = 'com.erebrus.drop'; + +/// Whether the current platform is a desktop OS. +bool get isDesktopPlatform { + if (Platform.isAndroid || Platform.isIOS) return false; + return Platform.isLinux || Platform.isMacOS || Platform.isWindows; +} diff --git a/lib/features/gateway/desktop_web_auth.dart b/lib/features/gateway/desktop_web_auth.dart new file mode 100644 index 0000000..b9e4f42 --- /dev/null +++ b/lib/features/gateway/desktop_web_auth.dart @@ -0,0 +1,140 @@ +import 'dart:math'; + +import 'auth_config.dart'; + +/// Parsed `erebrusdrop://auth` callback from the Erebrus webapp. +class DesktopAuthCallback { + const DesktopAuthCallback({ + required this.token, + required this.userId, + required this.walletAddress, + required this.role, + required this.state, + }); + + final String token; + final String userId; + final String walletAddress; + final String role; + final String state; + + bool get isValid => + token.isNotEmpty && userId.isNotEmpty && walletAddress.isNotEmpty && state.isNotEmpty; +} + +/// Browser-based sign-in for desktop (and mobile fallback). +/// +/// Opens `{EREBRUS_WEB_ORIGIN}/auth` with a redirect back to +/// [kErebrusAuthCallback]. The webapp completes wallet/social/email auth and +/// redirects with a PASETO bearer token in the query string. +class DesktopWebAuth { + DesktopWebAuth._(); + + static String? _pendingState; + + static String? get pendingState => _pendingState; + + static bool isAuthCallback(String url) { + if (url.isEmpty) return false; + final uri = Uri.tryParse(url); + if (uri == null) return false; + return uri.scheme == kErebrusAuthCallbackScheme && + (uri.host == kErebrusAuthCallbackHost || uri.path.startsWith('/auth')); + } + + static String buildLoginUrl() { + final state = _newState(); + _pendingState = state; + return Uri.parse('$kErebrusWebOrigin$kErebrusDesktopAuthPath').replace( + queryParameters: { + 'redirect_uri': kErebrusAuthCallback, + 'state': state, + 'platform': isDesktopPlatform ? 'desktop' : 'mobile', + 'client_id': kErebrusBundleId, + }, + ).toString(); + } + + /// Parses pasted text: full `erebrusdrop://auth?…` URL, query string, or raw PASETO. + static DesktopAuthCallback? parseManualAuthInput(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) return null; + + if (trimmed.contains('://') || + trimmed.contains('token=') || + trimmed.contains('paseto=')) { + var url = trimmed; + if (!trimmed.contains('://')) { + url = '$kErebrusAuthCallback${trimmed.startsWith('?') ? trimmed : '?$trimmed'}'; + } + final parsed = parseCallback(url); + if (parsed != null && parsed.token.isNotEmpty) return parsed; + } + + if (trimmed.startsWith('v4.')) { + return DesktopAuthCallback( + token: trimmed, + userId: '', + walletAddress: '', + role: 'user', + state: '', + ); + } + + return null; + } + + static DesktopAuthCallback? parseCallback(String url) { + if (!isAuthCallback(url)) return null; + final uri = Uri.parse(url); + final params = {...uri.queryParameters, ..._fragmentParams(uri)}; + + final error = params['error'] ?? params['error_description']; + if (error != null && error.isNotEmpty) { + throw DesktopWebAuthException(error); + } + + final token = params['token'] ?? params['paseto'] ?? ''; + final userId = params['user_id'] ?? params['userId'] ?? ''; + final wallet = params['wallet'] ?? params['wallet_address'] ?? params['public_key'] ?? ''; + final role = params['role'] ?? 'user'; + final state = params['state'] ?? ''; + + if (token.isEmpty) return null; + + return DesktopAuthCallback( + token: token, + userId: userId, + walletAddress: wallet, + role: role, + state: state, + ); + } + + static void clearPendingState() => _pendingState = null; + + static void validateState(String state) { + final expected = _pendingState; + if (expected == null || expected.isEmpty || state != expected) { + throw const DesktopWebAuthException('Sign-in state mismatch — try again'); + } + } + + static Map _fragmentParams(Uri uri) { + if (uri.fragment.isEmpty) return const {}; + return Uri.splitQueryString(uri.fragment); + } + + static String _newState() { + final r = Random.secure(); + return List.generate(16, (_) => r.nextInt(256).toRadixString(16).padLeft(2, '0')).join(); + } +} + +class DesktopWebAuthException implements Exception { + const DesktopWebAuthException(this.message); + final String message; + + @override + String toString() => message; +} diff --git a/lib/features/gateway/drop_auth_client.dart b/lib/features/gateway/drop_auth_client.dart new file mode 100644 index 0000000..92280b4 --- /dev/null +++ b/lib/features/gateway/drop_auth_client.dart @@ -0,0 +1,187 @@ +import 'gateway_config.dart'; +import 'gateway_http.dart'; +import 'gateway_models.dart'; + +/// Login methods the gateway has configured (`GET /api/v2/auth/methods`). +class DropAuthMethods { + const DropAuthMethods({ + this.wallet = true, + this.email = true, + this.google = false, + this.apple = false, + }); + + final bool wallet; + final bool email; + final bool google; + final bool apple; + + static const unknown = DropAuthMethods(); +} + +/// Wallet/social auth against the Erebrus gateway (v2). +class DropAuthClient { + DropAuthClient({String? gatewayUrl}) + : _base = GatewayHttp.normalizeBase(gatewayUrl ?? resolveGatewayUrl()); + + final Uri _base; + + String get baseUrl { + final port = _base.hasPort ? ':${_base.port}' : ''; + return '${_base.scheme}://${_base.host}$port'; + } + + /// `GET /api/v2/auth` — fetch a challenge for [walletAddress]. + Future fetchAuthChallenge({ + required String walletAddress, + String chain = 'sol', + }) async { + final uri = GatewayHttp.apiUri( + _base, + path: '/api/v2/auth', + query: {'wallet_address': walletAddress, 'chain': chain}, + ); + final map = await GatewayHttp.getJson(uri); + return DropAuthChallenge( + challengeId: (map['flow_id'] ?? '').toString(), + message: (map['message'] ?? '').toString(), + ); + } + + /// `POST /api/v2/auth` — complete wallet-signature login. + Future authenticate({ + required String challengeId, + required String signature, + required String publicKey, + String? referralCode, + }) async { + final body = { + 'flow_id': challengeId, + 'signature': signature, + 'public_key': publicKey, + }; + final ref = referralCode?.trim(); + if (ref != null && ref.isNotEmpty) { + body['ref'] = ref; + } + final map = await GatewayHttp.postJson( + GatewayHttp.apiUri(_base, path: '/api/v2/auth'), + body, + ); + return DropAuthSession( + token: (map['token'] ?? '').toString(), + userId: (map['user_id'] ?? '').toString(), + role: (map['role'] ?? 'user').toString(), + walletAddress: publicKey, + ); + } + + /// `GET /api/v2/orgs` — list organizations the caller belongs to. + Future> fetchOrgs(String bearerToken) async { + final list = await GatewayHttp.getJsonList( + GatewayHttp.apiUri(_base, path: '/api/v2/orgs'), + bearerToken: bearerToken, + ); + return list + .whereType>() + .map((e) => DropOrg.fromJson(Map.from(e))) + .where((o) => o.id.isNotEmpty) + .toList(); + } + + /// `POST /api/v2/orgs` — create a new organization. + Future createOrg({ + required String bearerToken, + required String name, + required String slug, + }) async { + final map = await GatewayHttp.postJson( + GatewayHttp.apiUri(_base, path: '/api/v2/orgs'), + {'name': name.trim(), 'slug': slug.trim().toLowerCase()}, + bearerToken: bearerToken, + ); + return DropOrg.fromJson(map); + } + + /// `GET /api/v2/auth/methods` — which login methods the gateway supports. + Future fetchAuthMethods() async { + final map = await GatewayHttp.getJson( + GatewayHttp.apiUri(_base, path: '/api/v2/auth/methods'), + ); + return DropAuthMethods( + wallet: map['wallet'] != false, + email: map['email'] == true, + google: map['google'] == true, + apple: map['apple'] == true, + ); + } + + /// `POST /api/v2/auth/email/login/start` — send a login code to the email. + Future emailLoginStart(String email) async { + await GatewayHttp.postJson( + GatewayHttp.apiUri(_base, path: '/api/v2/auth/email/login/start'), + {'email': email.trim().toLowerCase()}, + ); + } + + /// `POST /api/v2/auth/email/login/verify` — verify the code, get a session. + Future emailLoginVerify({ + required String email, + required String code, + }) async { + final normalizedCode = code.replaceAll(RegExp(r'\D'), ''); + final map = await GatewayHttp.postJson( + GatewayHttp.apiUri(_base, path: '/api/v2/auth/email/login/verify'), + {'email': email.trim().toLowerCase(), 'code': normalizedCode}, + ); + return _identitySession(map); + } + + /// `POST /api/v2/auth/google` — exchange a Google id_token for a session. + Future googleLogin(String idToken) async { + final map = await GatewayHttp.postJson( + GatewayHttp.apiUri(_base, path: '/api/v2/auth/google'), + {'id_token': idToken}, + ); + return _identitySession(map); + } + + /// `POST /api/v2/auth/apple` — exchange an Apple id_token for a session. + Future appleLogin(String idToken) async { + final map = await GatewayHttp.postJson( + GatewayHttp.apiUri(_base, path: '/api/v2/auth/apple'), + {'id_token': idToken}, + ); + return _identitySession(map); + } + + DropAuthSession _identitySession(Map map) => DropAuthSession( + token: (map['token'] ?? '').toString(), + userId: (map['user_id'] ?? '').toString(), + role: (map['role'] ?? 'user').toString(), + walletAddress: (map['wallet_address'] ?? + map['wallet'] ?? + map['public_key'] ?? + '') + .toString(), + ); +} + +class DropAuthChallenge { + const DropAuthChallenge({required this.challengeId, required this.message}); + final String challengeId; + final String message; +} + +class DropAuthSession { + const DropAuthSession({ + required this.token, + required this.userId, + required this.role, + required this.walletAddress, + }); + final String token; + final String userId; + final String role; + final String walletAddress; +} diff --git a/lib/features/gateway/drop_auth_service.dart b/lib/features/gateway/drop_auth_service.dart new file mode 100644 index 0000000..04eb68f --- /dev/null +++ b/lib/features/gateway/drop_auth_service.dart @@ -0,0 +1,752 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:app_links/app_links.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:reown_appkit/reown_appkit.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:solana/base58.dart' show base58encode; +import 'package:url_launcher/url_launcher.dart'; + +import '../../core/platform_wallet.dart'; +import '../wallet/solana_device_detector.dart'; +import '../wallet/solana_wallet_option.dart'; +import '../wallet/solana_wallet_picker_sheet.dart'; +import '../wallet/solana_wallet_service.dart'; +import 'auth_config.dart'; +import 'desktop_web_auth.dart'; +import 'drop_auth_client.dart'; +import 'drop_gateway_client.dart'; +import 'gateway_models.dart'; +import 'social_login.dart'; + +/// Auth/session and org state for the Erebrus Drop gateway. +/// +/// Supports multiple login paths: +/// - Reown AppKit modal (wallet + social/email) on mobile +/// - Solana Mobile Wallet Adapter on Seeker/Saga +/// - Native Google / Apple sign-in +/// - Gateway email OTP +/// - Desktop browser webauth fallback (erebrusdrop:// callback) +class DropAuthService { + DropAuthService({ + required this.solana, + DropAuthClient? authClient, + DropGatewayClient? gatewayClient, + }) : _authClient = authClient ?? DropAuthClient(), + _gatewayClient = gatewayClient ?? DropGatewayClient(); + + final SolanaWalletService solana; + final DropAuthClient _authClient; + final DropGatewayClient _gatewayClient; + + String? _bearerToken; + String? _walletAddress; + String? _userId; + String? _authMethod; + String? _mwaAuthToken; + ReownAppKitModal? _appKitModal; + + final ValueNotifier> orgs = ValueNotifier>(const []); + final ValueNotifier selectedOrg = ValueNotifier(null); + final ValueNotifier isAuthenticating = ValueNotifier(false); + final ValueNotifier error = ValueNotifier(null); + final ValueNotifier reownReady = ValueNotifier(false); + final ValueNotifier awaitingWebCallback = ValueNotifier(false); + final ValueNotifier solanaMobileDevice = ValueNotifier(false); + final ValueNotifier appleDeviceReady = ValueNotifier(false); + final ValueNotifier authMethods = + ValueNotifier(DropAuthMethods.unknown); + + final ValueNotifier signedIn = ValueNotifier(false); + + String? get bearerToken => _bearerToken; + String? get walletAddress => _walletAddress; + String? get userId => _userId; + String? get authMethod => _authMethod; + bool get isSignedIn => signedIn.value; + DropGatewayClient get gatewayClient => _gatewayClient; + + /// Combined [Listenable] for UI state observers. + Listenable get state => Listenable.merge([ + signedIn, + isAuthenticating, + error, + reownReady, + awaitingWebCallback, + solanaMobileDevice, + appleDeviceReady, + authMethods, + ]); + + bool get emailLoginAvailable => authMethods.value.email; + bool get googleLoginAvailable => authMethods.value.google && googleSignInSupported; + bool get appleLoginAvailable => authMethods.value.apple && appleDeviceReady.value; + + static const String _kToken = 'erebrus_gateway_token'; + static const String _kWallet = 'erebrus_gateway_wallet'; + static const String _kUserId = 'erebrus_gateway_user_id'; + static const String _kAuthMethod = 'erebrus_gateway_auth_method'; + static const String _kMwaToken = 'erebrus_gateway_mwa_token'; + static const String _kSelectedOrgId = 'erebrus_selected_org_id'; + static const String _kSelectedOrgSlug = 'erebrus_selected_org_slug'; + static const String _kSelectedOrgName = 'erebrus_selected_org_name'; + + /// Restores session and starts deep-link + auth-method discovery. + Future loadSession() async { + await _detectDevice(); + await _loadAuthMethods(); + await _listenDeepLinks(); + + final prefs = await SharedPreferences.getInstance(); + final token = prefs.getString(_kToken); + if (token == null || token.isEmpty) return; + + _mwaAuthToken = prefs.getString(_kMwaToken); + _restoreSession(token); + _walletAddress = prefs.getString(_kWallet); + _userId = prefs.getString(_kUserId); + _authMethod = prefs.getString(_kAuthMethod); + + await _loadOrgs(); + await _restoreSelectedOrg(prefs); + } + + /// Initializes Reown AppKit once a [BuildContext] is available. + Future initReown(BuildContext context) async { + if (solanaMobileDevice.value || isDesktopPlatform) return; + if (!hasReownProjectId) { + error.value = kReownProjectIdMissingMessage; + reownReady.value = false; + return; + } + if (_appKitModal != null) { + reownReady.value = true; + return; + } + + ReownAppKitModalNetworks.removeSupportedNetworks('eip155'); + ReownAppKitModalNetworks.removeTestNetworks(); + + final solanaChains = ReownAppKitModalNetworks.getAllSupportedNetworks( + namespace: 'solana', + ); + final solanaNamespaces = solanaChains.isEmpty + ? null + : { + 'solana': RequiredNamespace( + chains: solanaChains.map((c) => c.chainId).toList(), + methods: const ['solana_signMessage', 'solana_signTransaction'], + events: const [], + ), + }; + + await PackageInfo.fromPlatform(); + if (!context.mounted) { + reownReady.value = false; + return; + } + + _appKitModal = ReownAppKitModal( + context: context, + projectId: kReownProjectId, + logLevel: LogLevel.error, + metadata: PairingMetadata( + name: 'Erebrus Drop', + description: 'Local-first secure Drop Room file transfer', + url: erebrusSiteUrlFromOrigin(kErebrusWebOrigin), + icons: [erebrusSiteIconFromOrigin(kErebrusWebOrigin)], + redirect: const Redirect( + native: kErebrusNativeRedirect, + universal: kErebrusUniversalRedirect, + linkMode: false, + ), + ), + optionalNamespaces: solanaNamespaces, + featuresConfig: FeaturesConfig( + showMainWallets: true, + socials: const [ + AppKitSocialOption.Google, + AppKitSocialOption.Apple, + AppKitSocialOption.Email, + AppKitSocialOption.X, + ], + ), + disconnectOnDispose: false, + ); + + try { + await _appKitModal!.init(); + _appKitModal!.onModalConnect.subscribe(_onModalConnect); + _appKitModal!.onModalDisconnect.subscribe(_onModalDisconnect); + _appKitModal!.onModalError.subscribe(_onModalError); + reownReady.value = true; + + if (_appKitModal!.isConnected && !isSignedIn) { + await _authenticateConnectedWallet(); + } + } catch (e) { + debugPrint('[Reown] init failed: $e'); + error.value = 'Wallet connect failed to start: $e'; + reownReady.value = false; + } + } + + Future openReownModal() async { + error.value = null; + if (_appKitModal == null || !reownReady.value) { + error.value = 'Wallet connect is still starting — try again in a moment'; + return; + } + await _appKitModal!.openModalView(); + } + + /// Opens the browser-based Erebrus sign-in (desktop fallback / mobile generic). + Future openWebSignIn() async { + if (isAuthenticating.value || awaitingWebCallback.value) return; + error.value = null; + awaitingWebCallback.value = true; + try { + final url = DesktopWebAuth.buildLoginUrl(); + final uri = Uri.parse(url); + final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!launched) { + awaitingWebCallback.value = false; + error.value = 'Could not open the browser — check your default browser'; + } + } catch (e) { + awaitingWebCallback.value = false; + error.value = e.toString(); + } + } + + /// Handles an `erebrusdrop://auth?token=...` callback from the browser. + Future handleWebAuthCallback(String url) async { + if (!DesktopWebAuth.isAuthCallback(url)) return; + awaitingWebCallback.value = false; + isAuthenticating.value = true; + error.value = null; + try { + final callback = DesktopWebAuth.parseCallback(url); + if (callback == null || !callback.isValid) { + error.value = 'Sign-in callback was incomplete — try again'; + return; + } + DesktopWebAuth.validateState(callback.state); + final session = DropAuthSession( + token: callback.token, + userId: callback.userId, + role: callback.role, + walletAddress: callback.walletAddress, + ); + await _persistSession(session, method: 'web'); + DesktopWebAuth.clearPendingState(); + await _loadOrgs(); + } on DesktopWebAuthException catch (e) { + error.value = e.message; + } catch (e) { + error.value = e.toString(); + } finally { + isAuthenticating.value = false; + } + } + + /// Allows pasting a PASETO or full callback URL. + Future signInFromClipboard() async { + final data = await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text?.trim() ?? ''; + if (text.isEmpty) { + error.value = 'Clipboard is empty — copy the sign-in token first'; + return; + } + await signInWithPastedCredential(text); + } + + Future signInWithPastedCredential(String input) async { + isAuthenticating.value = true; + awaitingWebCallback.value = false; + error.value = null; + try { + final callback = DesktopWebAuth.parseManualAuthInput(input); + if (callback == null || callback.token.isEmpty) { + error.value = 'Could not read a sign-in token — paste the PASETO or full callback URL'; + return; + } + final session = DropAuthSession( + token: callback.token, + userId: callback.userId.isEmpty ? 'imported' : callback.userId, + role: callback.role.isEmpty ? 'user' : callback.role, + walletAddress: callback.walletAddress.isEmpty ? 'imported' : callback.walletAddress, + ); + await _persistSession(session, method: 'manual_paste'); + DesktopWebAuth.clearPendingState(); + await _loadOrgs(); + } catch (e) { + error.value = e.toString(); + } finally { + isAuthenticating.value = false; + } + } + + /// Solana Mobile Wallet Adapter sign-in (Seeker / Saga). + Future signInWithSolanaMobile(BuildContext context) async { + if (!Platform.isAndroid || !solanaMobileDevice.value) { + error.value = 'Solana Mobile sign-in is only available on Seeker and Saga'; + return; + } + if (isAuthenticating.value) return; + + isAuthenticating.value = true; + error.value = null; + try { + final wallet = await _pickOrUseConnectedSolanaWallet(context); + if (wallet == null) { + error.value = 'No wallet selected'; + return; + } + final address = solana.walletAddress; + if (address == null) { + error.value = 'Wallet did not return an address'; + return; + } + + final challenge = await _authClient.fetchAuthChallenge(walletAddress: address); + if (challenge.message.isEmpty) { + error.value = 'Gateway returned an empty challenge'; + return; + } + + final authToken = solana.authToken; + if (authToken == null || authToken.isEmpty) { + error.value = 'Wallet is not authorized'; + return; + } + + final signature = await PlatformWallet.signMessage( + wallet: wallet, + authToken: authToken, + message: challenge.message, + ); + + final session = await _authClient.authenticate( + challengeId: challenge.challengeId, + signature: signature, + publicKey: address, + ); + + _mwaAuthToken = authToken; + await _persistSession(session, method: 'solana_mobile', mwaToken: authToken); + await _loadOrgs(); + } catch (e) { + error.value = e.toString(); + } finally { + isAuthenticating.value = false; + } + } + + Future signInWithGoogle() async { + if (isAuthenticating.value || !googleLoginAvailable) return; + isAuthenticating.value = true; + error.value = null; + try { + final idToken = await googleIdToken(); + if (idToken == null) return; + final session = await _authClient.googleLogin(idToken); + await _persistSession(session, method: 'google'); + await _loadOrgs(); + } on AuthException catch (e) { + error.value = e.message; + } on SocialLoginException catch (e) { + error.value = e.message; + } catch (e) { + error.value = e.toString(); + } finally { + isAuthenticating.value = false; + } + } + + Future signInWithApple() async { + if (isAuthenticating.value || !appleLoginAvailable) return; + isAuthenticating.value = true; + error.value = null; + try { + final idToken = await appleIdToken(); + if (idToken == null) return; + final session = await _authClient.appleLogin(idToken); + await _persistSession(session, method: 'apple'); + await _loadOrgs(); + } on AuthException catch (e) { + error.value = e.message; + } on SocialLoginException catch (e) { + error.value = e.message; + } catch (e) { + error.value = e.toString(); + } finally { + isAuthenticating.value = false; + } + } + + Future requestEmailLoginCode(String email) async { + error.value = null; + try { + await _authClient.emailLoginStart(email.trim().toLowerCase()); + return true; + } on AuthException catch (e) { + error.value = e.message; + } catch (e) { + error.value = e.toString(); + } + return false; + } + + Future verifyEmailLoginCode({ + required String email, + required String code, + }) async { + final normalized = code.replaceAll(RegExp(r'\D'), ''); + if (normalized.length < 4) { + error.value = 'Enter the full code from your email'; + return; + } + isAuthenticating.value = true; + error.value = null; + try { + final session = await _authClient.emailLoginVerify( + email: email.trim().toLowerCase(), + code: normalized, + ); + if (session.token.isEmpty) { + error.value = 'Sign-in succeeded but no session token was returned'; + return; + } + await _persistSession(session, method: 'email'); + await _loadOrgs(); + } on AuthException catch (e) { + error.value = e.message; + } on TimeoutException { + error.value = 'Sign-in timed out — check your connection and try again'; + } catch (e) { + error.value = e.toString(); + } finally { + isAuthenticating.value = false; + } + } + + /// Creates a new organization and refreshes the org list. + Future createOrg({required String name, required String slug}) async { + if (!isSignedIn) throw const AuthException('Sign in first'); + final org = await _authClient.createOrg( + bearerToken: _bearerToken!, + name: name, + slug: slug, + ); + await _loadOrgs(); + if (selectedOrg.value == null) { + await selectOrg(org); + } + return org; + } + + /// Selects the active organization used for Drop discovery and uploads. + Future selectOrg(DropOrg org) async { + selectedOrg.value = org; + _gatewayClient.token = _bearerToken; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kSelectedOrgId, org.id); + await prefs.setString(_kSelectedOrgSlug, org.slug); + await prefs.setString(_kSelectedOrgName, org.name); + } + + /// Clears the signed-in session. + Future signOut() async { + _bearerToken = null; + _walletAddress = null; + _userId = null; + _authMethod = null; + _mwaAuthToken = null; + orgs.value = const []; + selectedOrg.value = null; + _gatewayClient.token = null; + signedIn.value = false; + error.value = null; + + final mwaToken = _mwaAuthToken; + unawaited(googleSignOut()); + if (mwaToken != null) { + unawaited(_disconnectSolanaMobile(mwaToken)); + } + try { + await _appKitModal?.disconnect(); + } catch (e) { + debugPrint('[Auth] Reown disconnect: $e'); + } + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kToken); + await prefs.remove(_kWallet); + await prefs.remove(_kUserId); + await prefs.remove(_kAuthMethod); + await prefs.remove(_kMwaToken); + await prefs.remove(_kSelectedOrgId); + await prefs.remove(_kSelectedOrgSlug); + await prefs.remove(_kSelectedOrgName); + } + + Future _loadAuthMethods() async { + try { + authMethods.value = await _authClient.fetchAuthMethods(); + } catch (e) { + debugPrint('[Auth] auth methods unavailable, using defaults: $e'); + } + try { + appleDeviceReady.value = await appleSignInSupported(); + } catch (_) { + appleDeviceReady.value = false; + } + } + + Future _detectDevice() async { + try { + solanaMobileDevice.value = await isSolanaMobileDevice(); + } catch (_) { + solanaMobileDevice.value = false; + } + } + + Future _listenDeepLinks() async { + if (isDesktopPlatform) return; + try { + final appLinks = AppLinks(); + appLinks.uriLinkStream.listen((uri) { + final url = uri.toString(); + if (DesktopWebAuth.isAuthCallback(url)) { + unawaited(handleWebAuthCallback(url)); + } else { + _appKitModal?.dispatchEnvelope(url); + } + }); + final initial = await appLinks.getInitialLink(); + if (initial != null) { + final url = initial.toString(); + if (DesktopWebAuth.isAuthCallback(url)) { + unawaited(handleWebAuthCallback(url)); + } + } + } catch (e) { + debugPrint('[Auth] deep link listener not available: $e'); + } + } + + Future _onModalConnect(ModalConnect? event) async { + if (event != null) await _authenticateConnectedWallet(); + } + + Future _onModalDisconnect(ModalDisconnect? event) async {} + + Future _onModalError(ModalError? event) async { + final message = event?.message; + if (message == null || message.isEmpty) return; + if (message.toLowerCase().contains('origin not allowed')) { + final packageInfo = await PackageInfo.fromPlatform(); + error.value = reownOriginNotAllowedMessage(packageInfo.packageName); + return; + } + error.value = message; + } + + Future _authenticateConnectedWallet() async { + final modal = _appKitModal; + if (modal == null || !modal.isConnected) return; + + final address = await _solanaAddressFromModal(modal); + if (address == null || address.isEmpty) { + error.value = + 'Connect a Solana wallet (Phantom, Solflare, etc.). For email sign-in, use Continue with Email — the wallet modal email option does not sign you into Erebrus.'; + return; + } + + isAuthenticating.value = true; + error.value = null; + try { + final challenge = await _authClient.fetchAuthChallenge(walletAddress: address); + final signature = await _signChallengeWithModal(modal, address, challenge.message); + final session = await _authClient.authenticate( + challengeId: challenge.challengeId, + signature: signature, + publicKey: address, + ); + await _persistSession(session, method: 'reown'); + await _loadOrgs(); + if (modal.isOpen) modal.closeModal(); + } on AuthException catch (e) { + error.value = e.message; + } catch (e) { + error.value = e.toString(); + } finally { + isAuthenticating.value = false; + } + } + + Future _solanaAddressFromModal(ReownAppKitModal modal) async { + var chainId = modal.selectedChain?.chainId ?? ''; + if (!chainId.startsWith('solana:')) { + final solChains = ReownAppKitModalNetworks.getAllSupportedNetworks( + namespace: 'solana', + ); + if (solChains.isNotEmpty) { + await modal.selectChain(solChains.first); + } + } + final selected = modal.selectedChain?.chainId ?? ''; + if (!selected.startsWith('solana:')) return null; + return modal.session?.getAddress('solana'); + } + + Future _signChallengeWithModal( + ReownAppKitModal modal, + String address, + String message, + ) async { + final chainId = modal.selectedChain!.chainId; + final messageBase58 = base58encode(Uint8List.fromList(utf8.encode(message))); + + final response = await modal.request( + topic: modal.session!.topic, + chainId: chainId, + request: SessionRequestParams( + method: 'solana_signMessage', + params: {'pubkey': address, 'message': messageBase58}, + ), + ); + + return _signatureToTransmittable(response); + } + + String _signatureToTransmittable(dynamic response) { + if (response is String) return response; + if (response is Map) { + final sig = _recursiveSearchForMapKey( + Map.from(response), + 'signature', + ); + if (sig is String) return sig; + } + if (response is List && response.isNotEmpty) { + return _signatureToTransmittable(response.first); + } + throw const AuthException('Wallet returned an unreadable signature'); + } + + dynamic _recursiveSearchForMapKey(Map map, String key) { + if (map.containsKey(key)) return map[key]; + for (final value in map.values) { + if (value is Map) { + final found = _recursiveSearchForMapKey(value, key); + if (found != null) return found; + } + } + return null; + } + + Future _pickOrUseConnectedSolanaWallet(BuildContext context) async { + if (solana.isConnected && solana.connectedWallet != null) { + return solana.connectedWallet; + } + final wallet = await showSolanaWalletPickerSheet( + context: context, + walletService: solana, + ); + if (wallet != null) await solana.connect(wallet: wallet); + return wallet; + } + + Future _disconnectSolanaMobile(String? mwaToken) async { + if (mwaToken == null || mwaToken.isEmpty || !Platform.isAndroid) return; + try { + final wallet = solana.connectedWallet; + if (wallet != null) { + await PlatformWallet.deauthorizeWallet( + wallet: wallet, + authToken: mwaToken, + ); + } + } catch (e) { + debugPrint('[Auth] MWA deauthorize: $e'); + } + } + + void _restoreSession(String token) { + _bearerToken = token; + _gatewayClient.token = token; + signedIn.value = true; + } + + Future _persistSession( + DropAuthSession session, { + required String method, + String? mwaToken, + }) async { + _bearerToken = session.token; + _walletAddress = session.walletAddress; + _userId = session.userId; + _authMethod = method; + if (mwaToken != null) _mwaAuthToken = mwaToken; + _gatewayClient.token = session.token; + signedIn.value = true; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kToken, session.token); + await prefs.setString(_kWallet, session.walletAddress); + await prefs.setString(_kUserId, session.userId); + await prefs.setString(_kAuthMethod, method); + if (mwaToken != null && mwaToken.isNotEmpty) { + await prefs.setString(_kMwaToken, mwaToken); + } + } + + Future _loadOrgs() async { + if (!isSignedIn) { + orgs.value = const []; + return; + } + try { + final list = await _authClient.fetchOrgs(_bearerToken!); + orgs.value = list; + if (selectedOrg.value == null && list.isNotEmpty) { + await selectOrg(list.first); + } + } catch (e) { + orgs.value = const []; + rethrow; + } + } + + Future _restoreSelectedOrg(SharedPreferences prefs) async { + final id = prefs.getString(_kSelectedOrgId); + if (id == null || id.isEmpty) { + if (orgs.value.isNotEmpty) { + await selectOrg(orgs.value.first); + } + return; + } + final match = orgs.value.firstWhere( + (o) => o.id == id, + orElse: () => orgs.value.isNotEmpty + ? orgs.value.first + : const DropOrg(id: '', name: '', slug: ''), + ); + if (match.id.isNotEmpty) { + await selectOrg(match); + } + } +} + +class AuthException implements Exception { + const AuthException(this.message); + final String message; + @override + String toString() => message; +} diff --git a/lib/features/gateway/drop_gateway_client.dart b/lib/features/gateway/drop_gateway_client.dart new file mode 100644 index 0000000..be3432f --- /dev/null +++ b/lib/features/gateway/drop_gateway_client.dart @@ -0,0 +1,156 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; + +import 'gateway_config.dart'; +import 'gateway_http.dart'; +import 'gateway_models.dart'; + +/// Client for the Erebrus gateway Drop endpoints. +class DropGatewayClient { + DropGatewayClient({String? gatewayUrl, this.bearerToken}) + : _base = GatewayHttp.normalizeBase(gatewayUrl ?? resolveGatewayUrl()); + + final Uri _base; + String? bearerToken; + + set token(String? value) => bearerToken = value; + + /// `GET /api/v2/drop/nodes` — public or private-org Drop-capable nodes. + Future> fetchDropNodes({ + String scope = 'public', + String? orgId, + }) async { + final query = {'scope': scope}; + if (orgId != null && orgId.isNotEmpty && scope == 'private') { + query['org_id'] = orgId; + } + final map = await GatewayHttp.getJson( + GatewayHttp.apiUri(_base, path: '/api/v2/drop/nodes', query: query), + bearerToken: bearerToken, + ); + final nodes = (map['nodes'] as List?) ?? []; + return nodes + .whereType>() + .map((e) => DropNode.fromJson(Map.from(e))) + .where((n) => n.nodeId.isNotEmpty) + .toList(); + } + + /// `POST /api/v2/drop/uploads` — reserve an upload slot on a node. + Future reserveUpload({ + required String nodeId, + required int sizeBytes, + required String filename, + required String contentType, + required String visibility, + String scope = 'public', + String? orgId, + String? sha256, + bool encrypted = false, + Map? encryptionMetadata, + String? idempotencyKey, + }) async { + final body = { + 'node_id': nodeId, + 'scope': scope, + 'visibility': visibility, + 'filename': filename, + 'content_type': contentType, + 'size_bytes': sizeBytes, + 'encrypted': encrypted, + 'idempotency_key': idempotencyKey ?? _randomIdempotencyKey(), + if (orgId != null && orgId.isNotEmpty && scope == 'private') + 'org_id': orgId, + if (sha256 != null && sha256.isNotEmpty) 'sha256': sha256, + if (encryptionMetadata != null && encryptionMetadata.isNotEmpty) + 'encryption_metadata': encryptionMetadata, + }; + final map = await GatewayHttp.postJson( + GatewayHttp.apiUri(_base, path: '/api/v2/drop/uploads'), + body, + bearerToken: bearerToken, + ); + return DropUploadReservation.fromJson(map); + } + + /// `PUT /api/v2/drop/uploads/{upload_id}/content` — stream [file] bytes. + /// Returns the committed file record. + Future uploadContent( + DropUploadReservation reservation, + File file, { + String? contentType, + }) async { + final uri = GatewayHttp.apiUri( + _base, + path: '/api/v2/drop/uploads/${reservation.uploadId}/content', + ); + final map = await GatewayHttp.putBytes( + uri, + file.openRead().cast>(), + contentLength: await file.length(), + contentType: contentType ?? 'application/octet-stream', + bearerToken: bearerToken, + ); + return DropGatewayFile.fromJson(map); + } + + /// Convenience: reserve + upload a local file to a node. + /// Returns the committed file (with `cid`). + Future uploadFile({ + required String nodeId, + required File file, + required String filename, + required String visibility, + String scope = 'public', + String? orgId, + String? contentType, + }) async { + final bytes = await file.readAsBytes(); + final digest = sha256.convert(bytes); + final sha = digest.toString(); + final reservation = await reserveUpload( + nodeId: nodeId, + sizeBytes: bytes.length, + filename: filename, + contentType: contentType ?? 'application/octet-stream', + visibility: visibility, + scope: scope, + orgId: orgId, + sha256: sha, + ); + return uploadContent(reservation, file, contentType: contentType); + } + + /// `GET /api/v2/drop/files` — caller's own Drop files. + Future> fetchMyFiles() async { + final map = await GatewayHttp.getJson( + GatewayHttp.apiUri(_base, path: '/api/v2/drop/files'), + bearerToken: bearerToken, + ); + final files = (map['files'] as List?) ?? []; + return files + .whereType>() + .map((e) => DropGatewayFile.fromJson(Map.from(e))) + .toList(); + } + + /// `GET /api/v2/orgs/{org_id}/drop/files` — org Drop files. + Future> fetchOrgFiles(String orgId) async { + final map = await GatewayHttp.getJson( + GatewayHttp.apiUri(_base, path: '/api/v2/orgs/$orgId/drop/files'), + bearerToken: bearerToken, + ); + final files = (map['files'] as List?) ?? []; + return files + .whereType>() + .map((e) => DropGatewayFile.fromJson(Map.from(e))) + .toList(); + } + + String _randomIdempotencyKey() { + final bytes = List.generate(16, (_) => 0); + return base64Encode(bytes).replaceAll(RegExp(r'[^A-Za-z0-9]'), ''); + } +} diff --git a/lib/features/gateway/gateway_config.dart b/lib/features/gateway/gateway_config.dart new file mode 100644 index 0000000..96afec8 --- /dev/null +++ b/lib/features/gateway/gateway_config.dart @@ -0,0 +1,21 @@ +/// Gateway base URL for the Erebrus network. +/// Override at build time via --dart-define=GATEWAY_URL=... +const String kDefaultGatewayUrl = 'https://gateway.erebrus.io'; + +const String _kGatewayUrlDefine = String.fromEnvironment( + 'GATEWAY_URL', + defaultValue: '', +); + +/// Resolves the gateway base URL: dart-define > production default. +String resolveGatewayUrl() { + final fromDefine = _kGatewayUrlDefine.trim(); + if (fromDefine.isNotEmpty) return fromDefine; + return kDefaultGatewayUrl; +} + +/// Client label sent to the gateway for diagnostics. +String gatewayClientHeader() { + // Drop app runs on all platforms; use a generic label. + return 'erebrus-drop'; +} diff --git a/lib/features/gateway/gateway_http.dart b/lib/features/gateway/gateway_http.dart new file mode 100644 index 0000000..bff7760 --- /dev/null +++ b/lib/features/gateway/gateway_http.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'gateway_config.dart'; + +/// Shared HTTP helpers for the Erebrus gateway Drop and auth APIs. +abstract final class GatewayHttp { + static const Duration _connectTimeout = Duration(seconds: 12); + static const Duration _requestTimeout = Duration(seconds: 20); + + static HttpClient createClient() { + final client = HttpClient(); + client.connectionTimeout = _connectTimeout; + client.idleTimeout = const Duration(seconds: 15); + return client; + } + + static Uri normalizeBase(String url) { + final trimmed = url.trim(); + final withScheme = trimmed.contains('://') ? trimmed : 'https://$trimmed'; + final uri = Uri.parse(withScheme); + final path = uri.path.replaceAll(RegExp(r'/+$'), ''); + if (path.isEmpty || path == '/') { + return uri.replace(path: '', query: null, fragment: null); + } + return uri.replace(path: path, query: null, fragment: null); + } + + static Uri apiUri( + Uri base, { + required String path, + Map? query, + }) { + final segment = path.startsWith('/') ? path : '/$path'; + final root = base.path.isEmpty || base.path == '/' + ? '' + : base.path.replaceAll(RegExp(r'/+$'), ''); + return base.replace(path: '$root$segment', queryParameters: query); + } + + static void applyHeaders( + HttpClientRequest req, { + String? bearerToken, + bool jsonBody = false, + int? contentLength, + String? contentType, + }) { + req.headers.set(HttpHeaders.acceptHeader, 'application/json'); + req.headers.set('X-Erebrus-Client', gatewayClientHeader()); + if (bearerToken != null && bearerToken.isNotEmpty) { + req.headers.set(HttpHeaders.authorizationHeader, 'Bearer $bearerToken'); + } + if (contentType != null && contentType.isNotEmpty) { + req.headers.set(HttpHeaders.contentTypeHeader, contentType); + } else if (jsonBody) { + req.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); + } + if (contentLength != null) { + req.contentLength = contentLength; + } + } + + static String errorMessage(int status, String body) { + try { + final j = jsonDecode(body); + if (j is Map) { + final msg = j['error'] ?? j['message'] ?? j['detail']; + if (msg != null) return msg.toString(); + } + } catch (_) {} + return switch (status) { + 404 => 'Gateway resource not found (404)', + 401 => 'Gateway authentication failed (401)', + 403 => 'Gateway access denied (403)', + 409 => 'Conflict (409)', + 413 => 'Request too large (413)', + 429 => 'Rate limit exceeded — slow down (429)', + 503 => 'Gateway service unavailable (503)', + 507 => 'Storage capacity exhausted (507)', + _ => 'Gateway error ($status)', + }; + } + + static Future _get(Uri uri, {String? bearerToken}) async { + final client = createClient(); + try { + final req = await client.getUrl(uri).timeout(_requestTimeout); + applyHeaders(req, bearerToken: bearerToken); + final res = await req.close().timeout(_requestTimeout); + final text = await utf8.decodeStream(res).timeout(_requestTimeout); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw GatewayException(errorMessage(res.statusCode, text)); + } + if (text.isEmpty) return null; + return jsonDecode(text); + } on SocketException catch (e) { + throw GatewayException('Cannot reach gateway: ${e.message}'); + } on TimeoutException { + throw GatewayException('Gateway request timed out'); + } finally { + client.close(force: true); + } + } + + static Future> getJson( + Uri uri, { + String? bearerToken, + }) async { + final decoded = await _get(uri, bearerToken: bearerToken); + if (decoded is Map) return Map.from(decoded); + return const {}; + } + + static Future> getJsonList( + Uri uri, { + String? bearerToken, + }) async { + final decoded = await _get(uri, bearerToken: bearerToken); + if (decoded is List) return decoded; + if (decoded is Map && decoded['items'] is List) { + return decoded['items'] as List; + } + return const []; + } + + static Future> postJson( + Uri uri, + Map body, { + String? bearerToken, + }) async { + return _request('POST', uri, body: body, bearerToken: bearerToken); + } + + static Future> putJson( + Uri uri, + Map body, { + String? bearerToken, + }) async { + return _request('PUT', uri, body: body, bearerToken: bearerToken); + } + + static Future> putBytes( + Uri uri, + Stream> bytes, { + required int contentLength, + required String contentType, + String? bearerToken, + }) async { + final client = createClient(); + try { + final req = await client.openUrl('PUT', uri).timeout(_requestTimeout); + applyHeaders( + req, + bearerToken: bearerToken, + contentLength: contentLength, + contentType: contentType, + ); + await req.addStream(bytes); + final res = await req.close().timeout(_requestTimeout); + final text = await utf8.decodeStream(res).timeout(_requestTimeout); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw GatewayException(errorMessage(res.statusCode, text)); + } + if (text.isEmpty) return const {}; + final decoded = jsonDecode(text); + if (decoded is Map) return Map.from(decoded); + return const {}; + } on SocketException catch (e) { + throw GatewayException('Cannot reach gateway: ${e.message}'); + } on TimeoutException { + throw GatewayException('Gateway request timed out'); + } finally { + client.close(force: true); + } + } + + static Future> _request( + String method, + Uri uri, { + required Map body, + String? bearerToken, + }) async { + final client = createClient(); + try { + final req = await client.openUrl(method, uri).timeout(_requestTimeout); + applyHeaders(req, bearerToken: bearerToken, jsonBody: true); + final encoded = jsonEncode(body); + req.contentLength = utf8.encode(encoded).length; + req.write(encoded); + final res = await req.close().timeout(_requestTimeout); + final text = await utf8.decodeStream(res).timeout(_requestTimeout); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw GatewayException(errorMessage(res.statusCode, text)); + } + if (text.isEmpty) return const {}; + final decoded = jsonDecode(text); + if (decoded is Map) return Map.from(decoded); + return const {}; + } on SocketException catch (e) { + throw GatewayException('Cannot reach gateway: ${e.message}'); + } on TimeoutException { + throw GatewayException('Gateway request timed out'); + } finally { + client.close(force: true); + } + } +} + +class GatewayException implements Exception { + GatewayException(this.message); + final String message; + + @override + String toString() => message; +} diff --git a/lib/features/gateway/gateway_models.dart b/lib/features/gateway/gateway_models.dart new file mode 100644 index 0000000..9565f74 --- /dev/null +++ b/lib/features/gateway/gateway_models.dart @@ -0,0 +1,185 @@ +/// An organization the signed-in user belongs to. +class DropOrg { + const DropOrg({ + required this.id, + required this.name, + required this.slug, + this.role, + this.plan, + this.verificationStatus, + }); + + final String id; + final String name; + final String slug; + final String? role; + final String? plan; + final String? verificationStatus; + + bool get verified => verificationStatus == 'verified'; + + factory DropOrg.fromJson(Map json) { + String? str(String key) { + final value = (json[key] ?? '').toString().trim(); + return value.isEmpty ? null : value; + } + + return DropOrg( + id: (json['id'] ?? json['org_id'] ?? '').toString(), + name: (json['name'] ?? '').toString(), + slug: (json['slug'] ?? '').toString(), + role: str('role'), + plan: str('plan'), + verificationStatus: str('verification_status'), + ); + } +} + +/// A Drop-capable node returned from the gateway discovery API. +class DropNode { + const DropNode({ + required this.nodeId, + required this.name, + this.orgId, + this.region = '', + this.accessMode = 'public', + this.deploymentProfile = 'standard', + this.online = true, + this.acceptingUploads = false, + this.state = '', + this.acceptsPublicUploads = false, + this.webUiAvailable = false, + this.capacity = 'unknown', + }); + + final String nodeId; + final String name; + final String? orgId; + final String region; + final String accessMode; + final String deploymentProfile; + final bool online; + final bool acceptingUploads; + final String state; + final bool acceptsPublicUploads; + final bool webUiAvailable; + final String capacity; + + bool get isPublic => accessMode.toLowerCase() == 'public'; + + factory DropNode.fromJson(Map json) { + return DropNode( + nodeId: (json['node_id'] ?? json['id'] ?? '').toString(), + name: (json['name'] ?? 'Erebrus node').toString(), + orgId: (json['org_id'] ?? '').toString().trim().isEmpty + ? null + : json['org_id'].toString(), + region: (json['region'] ?? '').toString(), + accessMode: (json['access_mode'] ?? 'public').toString(), + deploymentProfile: (json['deployment_profile'] ?? 'standard').toString(), + online: json['online'] == true, + acceptingUploads: json['accepting_uploads'] == true, + state: (json['state'] ?? '').toString(), + acceptsPublicUploads: json['accepts_public_uploads'] == true, + webUiAvailable: json['webui_available'] == true, + capacity: (json['capacity'] ?? 'unknown').toString(), + ); + } +} + +/// A gateway Drop upload reservation. +class DropUploadReservation { + const DropUploadReservation({ + required this.id, + required this.uploadId, + required this.nodeId, + required this.scope, + required this.visibility, + required this.filename, + required this.sizeBytes, + required this.status, + this.cid, + }); + + final String id; + final String uploadId; + final String nodeId; + final String scope; + final String visibility; + final String filename; + final int sizeBytes; + final String status; + final String? cid; + + factory DropUploadReservation.fromJson(Map json) { + return DropUploadReservation( + id: (json['id'] ?? json['upload_id'] ?? '').toString(), + uploadId: (json['upload_id'] ?? json['id'] ?? '').toString(), + nodeId: (json['node_id'] ?? '').toString(), + scope: (json['scope'] ?? 'public').toString(), + visibility: (json['visibility'] ?? 'private').toString(), + filename: (json['filename'] ?? '').toString(), + sizeBytes: (json['size_bytes'] ?? json['declared_size_bytes'] ?? 0) as int, + status: (json['status'] ?? '').toString(), + cid: json['cid']?.toString(), + ); + } +} + +/// A gateway Drop file returned from the files list. +class DropGatewayFile { + const DropGatewayFile({ + required this.id, + required this.fileId, + required this.nodeId, + this.orgId, + required this.scope, + required this.filename, + this.contentType, + required this.sizeBytes, + required this.visibility, + required this.encrypted, + required this.status, + this.cid, + required this.createdAt, + }); + + final String id; + final String fileId; + final String nodeId; + final String? orgId; + final String scope; + final String filename; + final String? contentType; + final int sizeBytes; + final String visibility; + final bool encrypted; + final String status; + final String? cid; + final DateTime createdAt; + + bool get isPublic => visibility.toLowerCase() == 'public'; + + factory DropGatewayFile.fromJson(Map json) { + final created = DateTime.tryParse( + json['created_at']?.toString() ?? '', + ); + return DropGatewayFile( + id: (json['id'] ?? json['file_id'] ?? '').toString(), + fileId: (json['file_id'] ?? json['id'] ?? '').toString(), + nodeId: (json['node_id'] ?? '').toString(), + orgId: (json['org_id'] ?? '').toString().trim().isEmpty + ? null + : json['org_id'].toString(), + scope: (json['scope'] ?? 'public').toString(), + filename: (json['filename'] ?? 'file').toString(), + contentType: json['content_type']?.toString(), + sizeBytes: (json['size_bytes'] ?? 0) as int, + visibility: (json['visibility'] ?? 'private').toString(), + encrypted: json['encrypted'] == true, + status: (json['status'] ?? 'available').toString(), + cid: json['cid']?.toString(), + createdAt: created ?? DateTime.now(), + ); + } +} diff --git a/lib/features/gateway/gateway_sheets.dart b/lib/features/gateway/gateway_sheets.dart new file mode 100644 index 0000000..11374bd --- /dev/null +++ b/lib/features/gateway/gateway_sheets.dart @@ -0,0 +1,239 @@ +import 'package:flutter/material.dart'; + +import '../../ui/theme/drop_theme.dart'; +import '../../ui/widgets/drop_widgets.dart'; +import 'drop_auth_service.dart'; +import 'gateway_models.dart'; + +enum GatewayOrgSheetResult { none, changed } + +Future showGatewayOrgSheet({ + required BuildContext context, + required DropAuthService authService, +}) async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (context) => GatewayOrgSheet(authService: authService), + ); + return result ?? GatewayOrgSheetResult.none; +} + +class GatewayOrgSheet extends StatefulWidget { + const GatewayOrgSheet({required this.authService, super.key}); + final DropAuthService authService; + + @override + State createState() => _GatewayOrgSheetState(); +} + +class _GatewayOrgSheetState extends State { + bool _creating = false; + final _nameController = TextEditingController(); + final _slugController = TextEditingController(); + bool _busy = false; + + @override + void dispose() { + _nameController.dispose(); + _slugController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final orgs = widget.authService.orgs.value; + final selected = widget.authService.selectedOrg.value; + final maxHeight = MediaQuery.sizeOf(context).height * 0.85; + + return Padding( + padding: EdgeInsets.fromLTRB( + 16, + 8, + 16, + MediaQuery.of(context).viewInsets.bottom + 12, + ), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Organization', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + Text( + 'Select the organization whose plan is used for Drop nodes.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + if (_creating) + _createForm() + else + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + for (final org in orgs) + _OrgRow( + org: org, + selected: selected?.id == org.id, + onTap: () async { + if (selected?.id == org.id) { + Navigator.of(context).pop(GatewayOrgSheetResult.none); + return; + } + final navigator = Navigator.of(context); + await widget.authService.selectOrg(org); + if (!mounted) return; + navigator.pop(GatewayOrgSheetResult.changed); + }, + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () => setState(() => _creating = true), + icon: const Icon(Icons.add_rounded), + label: const Text('Create new organization'), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: () async { + final navigator = Navigator.of(context); + await widget.authService.signOut(); + if (!mounted) return; + navigator.pop(GatewayOrgSheetResult.changed); + }, + icon: const Icon(Icons.logout_rounded), + label: const Text('Sign out'), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _createForm() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameController, + decoration: const InputDecoration(labelText: 'Organization name'), + enabled: !_busy, + ), + const SizedBox(height: 12), + TextField( + controller: _slugController, + decoration: const InputDecoration( + labelText: 'Slug (letters, numbers, dashes)', + hintText: 'my-org', + ), + enabled: !_busy, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: PrimaryButton( + label: _busy ? 'Creating…' : 'Create', + busy: _busy, + onPressed: _busy + ? null + : () async { + final name = _nameController.text.trim(); + final slug = _slugController.text.trim(); + if (name.isEmpty || slug.isEmpty) return; + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + setState(() => _busy = true); + try { + await widget.authService.createOrg( + name: name, + slug: slug, + ); + if (!mounted) return; + navigator.pop(GatewayOrgSheetResult.changed); + } catch (e) { + if (!mounted) return; + messenger.showSnackBar( + SnackBar(content: Text('Could not create org: $e')), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + }, + ), + ), + const SizedBox(width: 10), + TextButton( + onPressed: _busy ? null : () => setState(() => _creating = false), + child: const Text('Cancel'), + ), + ], + ), + ], + ); + } +} + +class _OrgRow extends StatelessWidget { + const _OrgRow({ + required this.org, + required this.selected, + required this.onTap, + }); + + final DropOrg org; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return PressableScale( + onTap: onTap, + child: DropCard( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + LeadingTile( + icon: selected ? Icons.check_circle_rounded : Icons.business_rounded, + accent: selected ? DropTheme.success : null, + size: 40, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + org.name, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 2), + Text( + '${org.plan ?? 'Free'} · ${org.slug}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + if (selected) + const Icon(Icons.check_rounded, color: DropTheme.success, size: 20), + ], + ), + ), + ); + } +} diff --git a/lib/features/gateway/login_screen.dart b/lib/features/gateway/login_screen.dart new file mode 100644 index 0000000..b1418ba --- /dev/null +++ b/lib/features/gateway/login_screen.dart @@ -0,0 +1,540 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../ui/theme/drop_theme.dart'; +import 'auth_config.dart'; +import 'desktop_web_auth.dart'; +import 'drop_auth_service.dart'; + +/// Multi-provider sign-in screen for Erebrus Drop. +/// +/// Mobile: Reown AppKit (wallet + social/email), native Google/Apple, gateway +/// email OTP, and Solana Mobile Wallet Adapter on Seeker/Saga. +/// Desktop: Browser sign-in with erebrusdrop:// callback + paste fallback. +class GatewayLoginScreen extends StatefulWidget { + const GatewayLoginScreen({super.key, required this.auth}); + final DropAuthService auth; + + @override + State createState() => _GatewayLoginScreenState(); +} + +class _GatewayLoginScreenState extends State { + @override + void initState() { + super.initState(); + widget.auth.signedIn.addListener(_onSignedIn); + if (!isDesktopPlatform && !widget.auth.solanaMobileDevice.value) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + await widget.auth.initReown(context); + }); + } + } + + @override + void dispose() { + widget.auth.signedIn.removeListener(_onSignedIn); + super.dispose(); + } + + void _onSignedIn() { + if (widget.auth.isSignedIn && mounted) { + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: DropTheme.black, + body: SafeArea( + child: ListenableBuilder( + listenable: widget.auth.state, + builder: (context, child) => Stack( + children: [ + _body(context), + if (widget.auth.isAuthenticating.value || widget.auth.awaitingWebCallback.value) + _loadingOverlay(), + ], + ), + ), + ), + ); + } + + Widget _body(BuildContext context) { + final isDesktop = isDesktopPlatform; + final solanaOnly = widget.auth.solanaMobileDevice.value; + final reownReady = widget.auth.reownReady.value; + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 36), + Text( + 'Welcome to Erebrus Drop', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: DropTheme.white, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 10), + Text( + 'Sign in to send files through Erebrus nodes.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: DropTheme.muted, + ), + ), + const SizedBox(height: 36), + if (isDesktop) ...[ + _PrimaryButton( + label: 'Sign in with browser', + icon: Icons.open_in_browser, + onPressed: () => widget.auth.openWebSignIn(), + ), + const SizedBox(height: 16), + _OutlinedButton( + label: 'Paste sign-in token', + icon: Icons.paste, + onPressed: () => _showPasteSheet(context), + ), + const SizedBox(height: 20), + Text( + 'Opens $kErebrusWebOrigin/auth in your browser.\n' + 'After you sign in, you\'ll return here automatically.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: DropTheme.faint, + ), + ), + ] else if (solanaOnly) ...[ + _PrimaryButton( + label: 'Connect Solana Wallet', + icon: Icons.account_balance_wallet, + onPressed: () => widget.auth.signInWithSolanaMobile(context), + ), + const SizedBox(height: 16), + Text( + 'Your Seed Vault wallet signs you in — no passwords.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: DropTheme.faint, + ), + ), + ] else ...[ + if (reownReady) ...[ + _PrimaryButton( + label: 'Continue with Wallet / Social', + icon: Icons.wallet, + onPressed: () => widget.auth.openReownModal(), + ), + const SizedBox(height: 12), + ], + if (widget.auth.googleLoginAvailable) ...[ + _OutlinedButton( + label: 'Continue with Google', + icon: Icons.g_mobiledata_rounded, + onPressed: () => widget.auth.signInWithGoogle(), + ), + const SizedBox(height: 12), + ], + if (widget.auth.appleLoginAvailable) ...[ + _OutlinedButton( + label: 'Continue with Apple', + icon: Icons.apple, + onPressed: () => widget.auth.signInWithApple(), + ), + const SizedBox(height: 12), + ], + if (widget.auth.emailLoginAvailable) ...[ + _OutlinedButton( + label: 'Continue with Email', + icon: Icons.mail_outline, + onPressed: () => _showEmailSheet(context), + ), + const SizedBox(height: 12), + ], + if (!reownReady && + !widget.auth.googleLoginAvailable && + !widget.auth.appleLoginAvailable && + !widget.auth.emailLoginAvailable) + _PrimaryButton( + label: 'Sign in with browser', + icon: Icons.open_in_browser, + onPressed: () => widget.auth.openWebSignIn(), + ), + const SizedBox(height: 20), + _TextButton( + label: 'Solana Mobile wallet', + onPressed: () => widget.auth.signInWithSolanaMobile(context), + ), + ], + const SizedBox(height: 20), + ValueListenableBuilder( + valueListenable: widget.auth.error, + builder: (context, err, _) { + if (err == null || err.isEmpty) return const SizedBox.shrink(); + return Text( + err, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: DropTheme.danger, + ), + ); + }, + ), + const SizedBox(height: 24), + Text( + 'By continuing you agree to Erebrus terms.', + textAlign: TextAlign.center, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: DropTheme.faint), + ), + ], + ), + ); + } + + Widget _loadingOverlay() { + return Container( + color: DropTheme.black.withValues(alpha: 0.75), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(color: DropTheme.orange), + const SizedBox(height: 16), + Text( + 'Connecting to Erebrus…', + style: TextStyle(color: DropTheme.white, fontSize: 14), + ), + ], + ), + ); + } + + void _showEmailSheet(BuildContext context) { + widget.auth.error.value = null; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: DropTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(22)), + ), + builder: (ctx) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom), + child: _EmailLoginSheet(auth: widget.auth), + ), + ); + } + + void _showPasteSheet(BuildContext context) { + widget.auth.error.value = null; + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: DropTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(22)), + ), + builder: (ctx) => Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom), + child: _PasteTokenSheet(auth: widget.auth), + ), + ); + } +} + +class _PrimaryButton extends StatelessWidget { + const _PrimaryButton({required this.label, required this.icon, required this.onPressed}); + final String label; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return FilledButton.icon( + onPressed: onPressed, + icon: Icon(icon, color: DropTheme.onAccent), + label: Text(label), + style: FilledButton.styleFrom( + backgroundColor: DropTheme.orange, + foregroundColor: DropTheme.onAccent, + minimumSize: const Size(0, 52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DropTheme.radiusButton), + ), + ), + ); + } +} + +class _OutlinedButton extends StatelessWidget { + const _OutlinedButton({required this.label, required this.icon, required this.onPressed}); + final String label; + final IconData icon; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon, color: DropTheme.white), + label: Text(label), + style: OutlinedButton.styleFrom( + foregroundColor: DropTheme.white, + minimumSize: const Size(0, 52), + side: const BorderSide(color: DropTheme.lineStrong), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DropTheme.radiusButton), + ), + ), + ); + } +} + +class _TextButton extends StatelessWidget { + const _TextButton({required this.label, required this.onPressed}); + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return TextButton( + onPressed: onPressed, + child: Text(label), + ); + } +} + +class _EmailLoginSheet extends StatefulWidget { + const _EmailLoginSheet({required this.auth}); + final DropAuthService auth; + + @override + State<_EmailLoginSheet> createState() => _EmailLoginSheetState(); +} + +class _EmailLoginSheetState extends State<_EmailLoginSheet> { + final _emailCtrl = TextEditingController(); + final _codeCtrl = TextEditingController(); + bool _codeSent = false; + bool _busy = false; + + @override + void dispose() { + _emailCtrl.dispose(); + _codeCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + _codeSent ? 'Enter your code' : 'Sign in with email', + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: DropTheme.white), + ), + const SizedBox(height: 8), + Text( + _codeSent + ? 'We sent a 6-digit code to ${_emailCtrl.text.trim()}' + : "We'll email you a one-time code.", + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: DropTheme.muted), + ), + const SizedBox(height: 18), + if (!_codeSent) ...[ + _InputField( + controller: _emailCtrl, + hint: 'you@example.com', + keyboardType: TextInputType.emailAddress, + autofocus: true, + ), + ] else ...[ + _InputField( + controller: _codeCtrl, + hint: '6-digit code', + keyboardType: TextInputType.number, + autofocus: true, + ), + ], + const SizedBox(height: 16), + FilledButton( + onPressed: _busy ? null : (_codeSent ? _verify : _send), + child: _busy ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2.5, color: DropTheme.onAccent)) : Text(_codeSent ? 'Verify' : 'Send code'), + ), + if (_codeSent) ...[ + const SizedBox(height: 10), + TextButton( + onPressed: _busy ? null : () => setState(() { _codeSent = false; _codeCtrl.clear(); }), + child: const Text('Use a different email'), + ), + ], + const SizedBox(height: 12), + ValueListenableBuilder( + valueListenable: widget.auth.error, + builder: (context, err, _) { + if (err == null || err.isEmpty) return const SizedBox.shrink(); + return Text( + err, + textAlign: TextAlign.center, + style: TextStyle(color: DropTheme.danger, fontSize: 13), + ); + }, + ), + ], + ), + ); + } + + Future _send() async { + final email = _emailCtrl.text.trim(); + if (email.isEmpty) return; + setState(() => _busy = true); + final ok = await widget.auth.requestEmailLoginCode(email); + if (!mounted) return; + setState(() { _busy = false; if (ok) _codeSent = true; }); + } + + Future _verify() async { + setState(() => _busy = true); + await widget.auth.verifyEmailLoginCode( + email: _emailCtrl.text.trim(), + code: _codeCtrl.text.trim(), + ); + if (!mounted) return; + setState(() => _busy = false); + if (widget.auth.isSignedIn) Navigator.pop(context); + } +} + +class _PasteTokenSheet extends StatefulWidget { + const _PasteTokenSheet({required this.auth}); + final DropAuthService auth; + + @override + State<_PasteTokenSheet> createState() => _PasteTokenSheetState(); +} + +class _PasteTokenSheetState extends State<_PasteTokenSheet> { + final _ctrl = TextEditingController(); + bool _busy = false; + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Paste sign-in token', + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: DropTheme.white), + ), + const SizedBox(height: 8), + Text( + 'Paste the full callback URL or PASETO token from the browser.', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: DropTheme.muted), + ), + const SizedBox(height: 18), + _InputField( + controller: _ctrl, + hint: 'erebrusdrop://auth?token=…', + autofocus: true, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _busy ? null : _submit, + child: _busy ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2.5, color: DropTheme.onAccent)) : const Text('Sign in'), + ), + const SizedBox(height: 12), + TextButton( + onPressed: _launchWeb, + child: const Text('Open Erebrus sign-in in browser'), + ), + ValueListenableBuilder( + valueListenable: widget.auth.error, + builder: (context, err, _) { + if (err == null || err.isEmpty) return const SizedBox.shrink(); + return Text( + err, + textAlign: TextAlign.center, + style: TextStyle(color: DropTheme.danger, fontSize: 13), + ); + }, + ), + ], + ), + ); + } + + Future _submit() async { + final input = _ctrl.text.trim(); + if (input.isEmpty) return; + setState(() => _busy = true); + await widget.auth.signInWithPastedCredential(input); + if (!mounted) return; + setState(() => _busy = false); + if (widget.auth.isSignedIn) Navigator.pop(context); + } + + Future _launchWeb() async { + final url = DesktopWebAuth.buildLoginUrl(); + final uri = Uri.parse(url); + await launchUrl(uri, mode: LaunchMode.externalApplication); + } +} + +class _InputField extends StatelessWidget { + const _InputField({ + required this.controller, + required this.hint, + this.keyboardType, + this.autofocus = false, + }); + final TextEditingController controller; + final String hint; + final TextInputType? keyboardType; + final bool autofocus; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + keyboardType: keyboardType, + autofocus: autofocus, + style: const TextStyle(color: DropTheme.white), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(color: DropTheme.faint), + filled: true, + fillColor: DropTheme.surfaceHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(DropTheme.radiusInput), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 16), + ), + ); + } +} diff --git a/lib/features/gateway/social_login.dart b/lib/features/gateway/social_login.dart new file mode 100644 index 0000000..e92d842 --- /dev/null +++ b/lib/features/gateway/social_login.dart @@ -0,0 +1,88 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:sign_in_with_apple/sign_in_with_apple.dart'; + +import 'auth_config.dart'; + +/// Whether native Google sign-in can run: a server client id is configured and +/// the platform is supported (Android / iOS). +bool get googleSignInSupported => + hasGoogleSignIn && (Platform.isAndroid || Platform.isIOS); + +/// Whether Apple sign-in can run: native on iOS/macOS, or a configured Services +/// id elsewhere. +Future appleSignInSupported() async { + if (Platform.isIOS || Platform.isMacOS) { + try { + return await SignInWithApple.isAvailable(); + } catch (_) { + return false; + } + } + return kAppleServiceId.isNotEmpty; +} + +/// Runs the Google sign-in sheet and returns an id_token, or null if cancelled. +Future googleIdToken() async { + final google = GoogleSignIn( + serverClientId: hasGoogleSignIn ? kGoogleServerClientId : null, + scopes: const ['email'], + ); + final account = await google.signIn(); + if (account == null) return null; + final auth = await account.authentication; + final token = auth.idToken; + if (token == null || token.isEmpty) { + throw const SocialLoginException('Google did not return an identity token'); + } + return token; +} + +/// Runs the Apple sign-in flow and returns the identity token, or null if +/// cancelled. +Future appleIdToken() async { + final useWebRelay = !(Platform.isIOS || Platform.isMacOS); + try { + final cred = await SignInWithApple.getAppleIDCredential( + scopes: const [ + AppleIDAuthorizationScopes.email, + AppleIDAuthorizationScopes.fullName, + ], + webAuthenticationOptions: useWebRelay && kAppleServiceId.isNotEmpty + ? WebAuthenticationOptions( + clientId: kAppleServiceId, + redirectUri: Uri.parse(kAppleRedirectUri), + ) + : null, + ); + final token = cred.identityToken; + if (token == null || token.isEmpty) { + throw const SocialLoginException('Apple did not return an identity token'); + } + return token; + } on SignInWithAppleAuthorizationException catch (e) { + if (e.code == AuthorizationErrorCode.canceled) return null; + throw SocialLoginException( + e.message.isEmpty ? 'Apple sign-in failed' : e.message, + ); + } +} + +/// Best-effort sign-out from the Google session so the chooser shows next time. +Future googleSignOut() async { + try { + await GoogleSignIn().signOut(); + } catch (e) { + debugPrint('[social] google signOut: $e'); + } +} + +class SocialLoginException implements Exception { + const SocialLoginException(this.message); + final String message; + + @override + String toString() => message; +} diff --git a/lib/features/wallet/solana_wallet_service.dart b/lib/features/wallet/solana_wallet_service.dart index 255940f..9404707 100644 --- a/lib/features/wallet/solana_wallet_service.dart +++ b/lib/features/wallet/solana_wallet_service.dart @@ -31,6 +31,8 @@ class SolanaWalletService { bool get isConnected => authToken != null && publicKey != null; + SolanaWalletOption? get connectedWallet => _connectedWallet; + String? get walletAddress { final key = publicKey; if (key == null) { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 82622ae..28b6eac 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,17 +6,25 @@ #include "generated_plugin_registrant.h" +#include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) gtk_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); + gtk_plugin_register_with_registrar(gtk_registrar); g_autoptr(FlPluginRegistrar) screen_retriever_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "ScreenRetrieverLinuxPlugin"); screen_retriever_linux_plugin_register_with_registrar(screen_retriever_linux_registrar); g_autoptr(FlPluginRegistrar) tray_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "TrayManagerPlugin"); tray_manager_plugin_register_with_registrar(tray_manager_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 0e9c9bb..fb5b67c 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,8 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + gtk screen_retriever_linux tray_manager + url_launcher_linux window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 6f777ca..d0354f7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,18 +5,34 @@ import FlutterMacOS import Foundation +import app_links import bonsoir_darwin +import connectivity_plus import device_info_plus +import google_sign_in_ios +import package_info_plus import screen_retriever_macos import shared_preferences_foundation +import sign_in_with_apple +import sqflite_darwin import tray_manager +import url_launcher_macos +import webview_flutter_wkwebview import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SignInWithApplePlugin.register(with: registry.registrar(forPlugin: "SignInWithApplePlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) TrayManagerPlugin.register(with: registry.registrar(forPlugin: "TrayManagerPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist index f674e13..05766d6 100644 --- a/macos/Runner/Info.plist +++ b/macos/Runner/Info.plist @@ -34,5 +34,16 @@ _erebrusdrop._tcp + CFBundleURLTypes + + + CFBundleURLName + com.erebrus.drop.auth + CFBundleURLSchemes + + erebrusdrop + + + diff --git a/pubspec.lock b/pubspec.lock index a93bad7..98cfa8d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" ansicolor: dependency: transitive description: @@ -9,6 +17,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "7546f09a6e93f4a2df2fe2bd40a5c6c64310ac461b036d82b43033be7a59f809" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + appcheck: + dependency: transitive + description: + name: appcheck + sha256: e23f3034f70c347e7287f50462e7eb5517322cb998331e51d387717bcd7017ae + url: "https://pub.dev" + source: hosted + version: "1.8.0" archive: dependency: transitive description: @@ -33,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + base_x: + dependency: transitive + description: + name: base_x + sha256: "519abcdafd637d4b6bd7e72fabd8f9264935f804b9b9f6c5d8411c7d52cbf8fd" + url: "https://pub.dev" + source: hosted + version: "2.0.1" bip39: dependency: transitive description: @@ -105,6 +161,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.2" + bs58: + dependency: transitive + description: + name: bs58 + sha256: "3ed24dadf386ca749ff50af678be1131ef569a3583a6f37b87b90c032270c767" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cached_network_image: + dependency: transitive + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" characters: dependency: transitive description: @@ -145,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + coinbase_wallet_sdk: + dependency: transitive + description: + name: coinbase_wallet_sdk + sha256: c51f3c54c728a6bdaec346de2ce4a3b6498f099a030772b7df22b058a1bad884 + url: "https://pub.dev" + source: hosted + version: "1.0.10" collection: dependency: transitive description: @@ -153,6 +249,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + connectivity_plus: + dependency: transitive + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.dev" + source: hosted + version: "2.1.0" convert: dependency: transitive description: @@ -193,6 +305,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + custom_sliding_segmented_control: + dependency: transitive + description: + name: custom_sliding_segmented_control + sha256: bc747b1500284bff36fc8c61774c9a3ca35c1dca4d570141c8a2569b4f984bf3 + url: "https://pub.dev" + source: hosted + version: "1.8.5" dbus: dependency: transitive description: @@ -213,18 +333,26 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd url: "https://pub.dev" source: hosted - version: "13.2.0" + version: "12.4.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface - sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd" url: "https://pub.dev" source: hosted - version: "8.1.0" + version: "0.3.1" ed25519_hd_key: dependency: transitive description: @@ -233,6 +361,30 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + eip1559: + dependency: transitive + description: + name: eip1559 + sha256: c2b81ac85f3e0e71aaf558201dd9a4600f051ece7ebacd0c5d70065c9b458004 + url: "https://pub.dev" + source: hosted + version: "0.6.2" + eip55: + dependency: transitive + description: + name: eip55 + sha256: "213a9b86add87a5216328e8494b0ab836e401210c4d55eb5e521bd39e39169e1" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + event: + dependency: transitive + description: + name: event + sha256: "5809a742e6274146a23d9cba63c24caf2f4c87a9a781fdf16ef07f052becaf72" + url: "https://pub.dev" + source: hosted + version: "3.1.0" fake_async: dependency: transitive description: @@ -249,14 +401,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - ffi_leak_tracker: - dependency: transitive - description: - name: ffi_leak_tracker - sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.dev" - source: hosted - version: "0.1.2" file: dependency: transitive description: @@ -265,11 +409,27 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter source: sdk version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" flutter_launcher_icons: dependency: "direct dev" description: @@ -294,6 +454,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.8" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -312,6 +480,70 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.4" + get_it: + dependency: transitive + description: + name: get_it + sha256: ae78de7c3f2304b8d81f2bb6e320833e5e81de942188542328f074978cc0efa9 + url: "https://pub.dev" + source: hosted + version: "8.3.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: d0a2c3bcb06e607bb11e4daca48bd4b6120f0bbc4015ccebbe757d24ea60ed2a + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: d5e23c56a4b84b6427552f1cf3f98f716db3b1d1a647f16b96dbb5b93afa2805 + url: "https://pub.dev" + source: hosted + version: "6.2.1" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: "102005f498ce18442e7158f6791033bbc15ad2dcc0afa4cf4752e2722a516c96" + url: "https://pub.dev" + source: hosted + version: "5.9.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "5f6f79cf139c197261adb6ac024577518ae48fdff8e53205c5373b5f6430a8aa" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: "460547beb4962b7623ac0fb8122d6b8268c951cf0b646dd150d60498430e4ded" + url: "https://pub.dev" + source: hosted + version: "0.12.4+4" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hex: dependency: transitive description: @@ -400,6 +632,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + json_rpc_2: + dependency: transitive + description: + name: json_rpc_2 + sha256: "246b321532f0e8e2ba474b4d757eaa558ae4fdd0688fdbc1e1ca9705f9b8ca0e" + url: "https://pub.dev" + source: hosted + version: "3.0.3" leak_tracker: dependency: transitive description: @@ -432,6 +672,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" logging: dependency: transitive description: @@ -472,6 +720,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + msgpack_dart: + dependency: transitive + description: + name: msgpack_dart + sha256: c2d235ed01f364719b5296aecf43ac330f0d7bc865fa134d0d7910a40454dffb + url: "https://pub.dev" + source: hosted + version: "1.0.1" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" objective_c: dependency: transitive description: @@ -480,6 +744,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.4.1" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" package_config: dependency: transitive description: @@ -488,6 +760,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -496,6 +784,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -616,6 +912,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + qr_flutter_wc: + dependency: transitive + description: + name: qr_flutter_wc + sha256: a926bf6bcf7d350eea48480e205ccffffca5cc84b5d77fb2b256318693e40d07 + url: "https://pub.dev" + source: hosted + version: "0.0.3" rational: dependency: transitive description: @@ -632,6 +936,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + reown_appkit: + dependency: "direct main" + description: + name: reown_appkit + sha256: "0d3c5a27a32569660e59202186b9d1aa926a51a8a10cc126e039e93f5ca6f548" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + reown_core: + dependency: transitive + description: + name: reown_core + sha256: "37e8bd16263400856592b58331ec61665bcc8814d4a2a1801a7f12caf3c4673d" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + reown_sign: + dependency: transitive + description: + name: reown_sign + sha256: ae2e171b93ddaae2ce7db18a66647afc9680cf55f49e88d49a3c3df5d1eb2cde + url: "https://pub.dev" + source: hosted + version: "1.2.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" screen_retriever: dependency: transitive description: @@ -672,6 +1008,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.1" + sec: + dependency: transitive + description: + name: sec + sha256: "8bbd56df884502192a441b5f5d667265498f2f8728a282beccd9db79e215f379" + url: "https://pub.dev" + source: hosted + version: "1.1.0" shared_preferences: dependency: "direct main" description: @@ -728,6 +1072,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shimmer: + dependency: transitive + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" shortid: dependency: transitive description: @@ -736,6 +1088,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + sign_in_with_apple: + dependency: "direct main" + description: + name: sign_in_with_apple + sha256: e84a62e17b7e463abf0a64ce826c2cd1f0b72dff07b7b275e32d5302d76fb4c5 + url: "https://pub.dev" + source: hosted + version: "6.1.4" + sign_in_with_apple_platform_interface: + dependency: transitive + description: + name: sign_in_with_apple_platform_interface + sha256: c2ef2ce6273fce0c61acd7e9ff5be7181e33d7aa2b66508b39418b786cca2119 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + sign_in_with_apple_web: + dependency: transitive + description: + name: sign_in_with_apple_web + sha256: "2f7c38368f49e3f2043bca4b46a4a61aaae568c140a79aa0675dc59ad0ca49bc" + url: "https://pub.dev" + source: hosted + version: "2.1.1" sky_engine: dependency: transitive description: flutter @@ -765,6 +1141,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: "58a799e6ac17dd32fbab93813d39ed835a75ccc0f8f85b8955fe318c6712b082" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: d0548f9d7422a2dae99ec6f8b0a3074463b132d216fa5ba0d230eeefc901983b + url: "https://pub.dev" + source: hosted + version: "2.4.3" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "5bf6a55c166e73bf651ba7ec3ed486e577620e3dc8f3a9c6a258a8031b624590" + url: "https://pub.dev" + source: hosted + version: "2.5.11" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: c86ca18b8f666bbf903924687fe21cc16fc385d086005067e26619ca530bef9f + url: "https://pub.dev" + source: hosted + version: "2.4.3+1" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: f84939f84350d92d04416f8bc4dc52d3896aec7716cc9e80cf0146342139dc50 + url: "https://pub.dev" + source: hosted + version: "2.4.1" stack_trace: dependency: transitive description: @@ -781,6 +1197,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -789,6 +1213,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153" + url: "https://pub.dev" + source: hosted + version: "3.4.1" term_glyph: dependency: transitive description: @@ -829,6 +1261,102 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.1" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "2306c03da2ba81724afeb589c351ebbc0aa7d86005925be8f8735856dbe5e42d" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "142a9146f447d15b10bdc00e21d5f4d83e5b32bb5f8f8f5a04c75311344923a3" + url: "https://pub.dev" + source: hosted + version: "1.2.6" vector_math: dependency: transitive description: @@ -845,6 +1373,14 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + wallet: + dependency: transitive + description: + name: wallet + sha256: "687fd89a16557649b26189e597792962f405797fc64113e8758eabc2c2605c32" + url: "https://pub.dev" + source: hosted + version: "0.0.13" web: dependency: transitive description: @@ -853,6 +1389,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web3dart: + dependency: transitive + description: + name: web3dart + sha256: "885e5e8f0cc3c87c09f160a7fce6279226ca41316806f7ece2001959c62ecced" + url: "https://pub.dev" + source: hosted + version: "2.7.3" web_socket: dependency: transitive description: @@ -869,22 +1413,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490 + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d + url: "https://pub.dev" + source: hosted + version: "3.26.0" win32: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "5.15.0" win32_registry: dependency: transitive description: name: win32_registry - sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "2.1.0" window_manager: dependency: "direct main" description: @@ -893,6 +1469,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.1" + x25519: + dependency: transitive + description: + name: x25519 + sha256: cec3c125f0d934dccba6c4cab48f3fbf866dc78895dcc5a1584d35b0a845005b + url: "https://pub.dev" + source: hosted + version: "0.1.1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 17c88a1..b50cf87 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,11 +40,17 @@ dependencies: flutter_native_splash: ^2.4.4 solana_mobile_client: ^0.1.2 solana: ^0.31.2+1 - device_info_plus: ^13.1.0 + device_info_plus: ^12.4.0 + package_info_plus: ^8.3.1 shared_preferences: ^2.5.5 bonsoir: ^7.1.4 tray_manager: ^0.5.3 window_manager: ^0.5.1 + reown_appkit: ^1.4.3+1 + google_sign_in: ^6.2.2 + sign_in_with_apple: ^6.1.4 + url_launcher: ^6.3.1 + app_links: ^6.3.3 dev_dependencies: flutter_test: sdk: flutter diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 2e22330..17db0c1 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,18 +6,27 @@ #include "generated_plugin_registrant.h" +#include #include +#include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AppLinksPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AppLinksPluginCApi")); BonsoirWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi")); + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); TrayManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("TrayManagerPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index ca78240..77a1794 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,9 +3,12 @@ # list(APPEND FLUTTER_PLUGIN_LIST + app_links bonsoir_windows + connectivity_plus screen_retriever_windows tray_manager + url_launcher_windows window_manager )