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