diff --git a/.github/workflows/atomic-flutter.yml b/.github/workflows/atomic-flutter.yml new file mode 100644 index 0000000000..6b2be87ff2 --- /dev/null +++ b/.github/workflows/atomic-flutter.yml @@ -0,0 +1,22 @@ +name: Atomic Flutter package +on: + pull_request: + paths: ['packages/atomic_flutter/**', 'flutter/**', '.github/workflows/atomic-flutter.yml'] + push: + paths: ['packages/atomic_flutter/**', 'flutter/**', '.github/workflows/atomic-flutter.yml'] +jobs: + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.44.7' + channel: stable + cache: true + - run: flutter pub get + working-directory: packages/atomic_flutter + - run: flutter analyze + working-directory: packages/atomic_flutter + - run: flutter test + working-directory: packages/atomic_flutter diff --git a/TESTING_COVERAGE.md b/TESTING_COVERAGE.md index e4df2cf834..47ab6b95c7 100644 --- a/TESTING_COVERAGE.md +++ b/TESTING_COVERAGE.md @@ -2380,3 +2380,13 @@ refused subscribe is dropped. - The paired SaaS `portal/e2e/onboarding.spec.ts` closes the original context, downloads the vault into a fresh browser, verifies the saved document and profile, requires a clean console, and budgets metadata reads after reload. + +## Flutter account package (draft) + +`packages/atomic_flutter/test/account_test.dart` covers device approval, provider +origin binding, redirect refusal, account/backup mismatch, authenticated discovery, +AES-GCM envelope compatibility with an independently generated Node fixture, +tamper rejection, and closing during a pending request. Atomic Audio additionally +checks isolated native identity installation and actual bidirectional iroh/Loro +with BLAKE3 files introduced after pairing. Production account approval, Vault +transport and recovery on a physical device are not covered by these tests. diff --git a/flutter/analysis_options.yaml b/flutter/analysis_options.yaml index 61b6c4de17..484a217529 100644 --- a/flutter/analysis_options.yaml +++ b/flutter/analysis_options.yaml @@ -27,3 +27,8 @@ linter: # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options + +# Cargokit is a vendored standalone Dart package, checked in its own context. +analyzer: + exclude: + - rust_builder/cargokit/** diff --git a/flutter/integration_test/simple_test.dart b/flutter/integration_test/simple_test.dart index 8bb1ee0e80..e5d45be7dd 100644 --- a/flutter/integration_test/simple_test.dart +++ b/flutter/integration_test/simple_test.dart @@ -1,13 +1,22 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:atomiccanvas_flutter/main.dart'; +import 'dart:io'; +import 'package:atomiccanvas_flutter/atomic/atomic_client.dart'; +import 'package:atomiccanvas_flutter/atomic/settings_backend.dart'; import 'package:atomiccanvas_flutter/src/rust/frb_generated.dart'; +import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - setUpAll(() async => await RustLib.init()); - testWidgets('Can call rust function', (WidgetTester tester) async { - await tester.pumpWidget(const MyApp()); - expect(find.textContaining('Result: `Hello, Tom!`'), findsOneWidget); + testWidgets('settings backend reads a native Atomic identity and drive', + (tester) async { + await RustLib.init(); + final dir = await Directory.systemTemp.createTemp('atomic-settings-test-'); + addTearDown(() => dir.delete(recursive: true)); + await AtomicClient.openDb(dir.path); + final account = await AtomicClient.setup('Package test'); + final backend = CanvasSettingsBackend(); + expect((await backend.getActiveAgent())?.subject, account.agentSubject); + expect(await backend.listDrives(), contains(account.driveSubject)); + expect(await backend.exportSecret(), account.agentSecret); }); } diff --git a/flutter/lib/atomic/settings_backend.dart b/flutter/lib/atomic/settings_backend.dart new file mode 100644 index 0000000000..4d1806530e --- /dev/null +++ b/flutter/lib/atomic/settings_backend.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:atomic_flutter/atomic_flutter.dart' as shared; +import 'atomic_client.dart'; +import 'atomic_auth.dart'; +import 'server_info.dart'; +import 'session.dart'; +import '../screens/pair_screen.dart'; + +/// Canvas keeps its existing native bridge, secure storage and transport. +/// Only the UI is shared with other Atomic applications. +class CanvasSettingsBackend extends shared.AtomicSettingsBackend { + @override + Future getActiveAgent() async { + final agent = await AtomicClient.getActiveAgent(); + return agent == null + ? null + : shared.AtomicIdentity(subject: agent.subject, name: agent.name); + } + + @override + Future exportSecret() async { + final agent = await AtomicClient.getActiveAgent(); + if (agent == null) throw StateError('No active identity'); + return agent.secret; + } + + @override + Future> listDrives() => AtomicClient.listDrives(); + @override + String? getActiveDrive() => AtomicClient.getActiveDrive(); + @override + Future getDriveName(String drive) => + AtomicClient.getProperty(drive, 'https://atomicdata.dev/properties/name'); + @override + Future createDrive(String name) async { + await AtomicClient.createDrive(name); + } + + @override + Future switchDrive(String drive) async { + await AtomicClient.setActiveDrive(drive); + await AtomicSession.saveDrive(drive); + } + + @override + Future getPeerId() => AtomicClient.getPeerId(); + @override + Future getDeviceName() async { + var name = await AtomicClient.getDeviceName(); + if (name.isEmpty) { + name = await PairScreen.getDeviceName(); + if (name.isNotEmpty && name != 'localhost') { + await AtomicClient.setDeviceName(name); + } + } + return name; + } + + @override + Future setDeviceName(String name) => AtomicClient.setDeviceName(name); + @override + Future>> getKnownPeers() => + AtomicClient.getKnownPeers(); + @override + Future> livePeerIds() async => AtomicClient.livePeerIds(); + @override + Future removeKnownPeer(String nodeId) => + AtomicClient.removeKnownPeer(nodeId); + @override + Future pair(BuildContext context) async { + await PairScreen.show(context); + } + + @override + shared.AtomicServerBackend get servers => CanvasServerBackend(); + @override + Future Function() get signOut => AtomicSession.clear; +} + +class CanvasServerBackend extends shared.AtomicServerBackend { + @override + Future> knownServers() => AtomicSession.knownServers(); + @override + Future activeServer() => AtomicSession.activeServer(); + @override + Future serverInfo(String url) async { + final info = await fetchServerInfo(url); + return shared.ServerInfo( + nodeId: info.nodeId, + version: info.version, + managed: info.managed, + portalUrl: info.portalUrl); + } + + @override + Future driveUsage(String url) async { + final session = await AtomicSession.load(); + if (session == null) return null; + final drive = AtomicClient.getActiveDrive() ?? session.drive; + if (drive == null) return null; + final usage = await fetchDriveUsage( + url, drive, AtomicAgent.fromSecret(session.secret)); + return usage == null + ? null + : shared.DriveUsage( + driveName: usage.driveName, + resourceCount: usage.resourceCount, + blobBytes: usage.blobBytes, + loroBytes: usage.loroBytes); + } + + @override + Future switchTo(String url) async { + await AtomicClient.closeWsSync(); + await AtomicSession.setActiveServer(url); + await AtomicClient.openWsSync(url); + } + + @override + Future add(String url) => AtomicSession.addKnownServer(url); + @override + Future remove(String url) async { + if (shared.sameOrigin(url, await AtomicSession.activeServer())) { + await AtomicClient.closeWsSync(); + } + await AtomicSession.removeKnownServer(url); + } + + @override + Future pushWorkspace(String url) => AtomicClient.syncDriveToServer(url); +} diff --git a/flutter/lib/atomic/widgets/agent_settings_dialog.dart b/flutter/lib/atomic/widgets/agent_settings_dialog.dart index 58b979c988..320fd43101 100644 --- a/flutter/lib/atomic/widgets/agent_settings_dialog.dart +++ b/flutter/lib/atomic/widgets/agent_settings_dialog.dart @@ -1,494 +1,18 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import '../atomic_client.dart'; -import '../session.dart'; -import '../../screens/pair_screen.dart'; -import '../../widgets/error_snack.dart'; -import 'server_settings_section.dart'; +import 'package:atomic_flutter/atomic_flutter.dart' as shared; +import '../settings_backend.dart'; -class AgentSettingsDialog extends StatefulWidget { +/// Compatibility entry point; the actual Canvas dialog lives in atomic_flutter. +class AgentSettingsDialog extends StatelessWidget { const AgentSettingsDialog({super.key}); - static Future show(BuildContext context) async { - final result = await showDialog( - context: context, - builder: (context) => const AgentSettingsDialog(), - ); - return result ?? false; - } - - @override - State createState() => _AgentSettingsDialogState(); -} - -class _AgentSettingsDialogState extends State { - AgentInfo? _agent; - List _drives = []; - Map _driveNames = {}; - String? _activeDrive; - bool _loading = true; - bool _creatingDrive = false; - bool _showNewDrive = false; - String? _peerId; - // The peer never starts from this dialog anymore (pairing is its own screen), - // but the "This device" card still reads it as an online/starting hint. - final bool _peerStarting = false; - String _deviceName = ''; - final _newDriveController = TextEditingController(); - - @override - void initState() { - super.initState(); - _loadData(); - _loadDeviceName(); - } - - @override - void dispose() { - _newDriveController.dispose(); - super.dispose(); - } - - void _loadDeviceName() async { - var name = await AtomicClient.getDeviceName(); - if (name.isEmpty) { - name = await PairScreen.getDeviceName(); - if (name.isNotEmpty && name != 'localhost') { - await AtomicClient.setDeviceName(name); - } - } - if (mounted) setState(() => _deviceName = name); - } - - // ── Actions ────────────────────────────────────────────────────────── - - Future _loadData() async { - setState(() => _loading = true); - final agent = await AtomicClient.getActiveAgent(); - final drives = await AtomicClient.listDrives(); - final activeDrive = AtomicClient.getActiveDrive(); - final peerId = await AtomicClient.getPeerId(); - - final names = {}; - for (final d in drives) { - try { - names[d] = await AtomicClient.getProperty( - d, 'https://atomicdata.dev/properties/name'); - } catch (_) { - names[d] = ''; - } - } - - setState(() { - _agent = agent; - _drives = drives; - _driveNames = names; - _activeDrive = activeDrive; - _peerId = peerId; - _loading = false; - }); - } - - Future _createDrive() async { - final name = _newDriveController.text.trim(); - if (name.isEmpty) return; - setState(() => _creatingDrive = true); - try { - await AtomicClient.createDrive(name); - _newDriveController.clear(); - setState(() => _showNewDrive = false); - await _loadData(); - } catch (e) { - if (mounted) showErrorSnack(context, 'Failed to create drive: $e'); - } - setState(() => _creatingDrive = false); - } - - Future _switchDrive(String drive) async { - try { - await AtomicClient.setActiveDrive(drive); - await AtomicSession.saveDrive(drive); - setState(() => _activeDrive = drive); - } catch (e) { - if (mounted) showErrorSnack(context, 'Failed to switch drive: $e'); - } - } - - Future _signOut() async { - final navigator = Navigator.of(context); - final confirm = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Sign out?'), - content: const Text( - 'Your local data will be kept, but you\'ll need your secret to sign back in.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel')), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Sign out'), - ), - ], - ), - ); - if (confirm != true) return; - await AtomicSession.clear(); - navigator.pop(true); - } - - void _copyToClipboard(String text, String label) { - Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('$label copied'), duration: const Duration(seconds: 2)), - ); - } - - // ── Build ──────────────────────────────────────────────────────────── + static Future show(BuildContext context) => + shared.AgentSettingsDialog.show( + context, + backend: CanvasSettingsBackend(), + ); @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - final screenWidth = MediaQuery.of(context).size.width; - final isPhone = screenWidth < 600; - final dialogWidth = isPhone ? screenWidth * 0.92 : 420.0; - - return AlertDialog( - title: const Text('Settings'), - insetPadding: EdgeInsets.symmetric( - horizontal: isPhone ? 12 : 40, - vertical: 24, - ), - content: _loading - ? const SizedBox( - height: 200, child: Center(child: CircularProgressIndicator())) - : SizedBox( - width: dialogWidth, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // This device, then the devices it syncs with, then the - // code to add another — the same order as the browser Sync - // page. A server is one of those devices (an always-on - // one), not a category of its own. - _buildThisDeviceCard(theme), - - const SizedBox(height: 16), - - // ── Devices (servers + paired devices, incl. QR pairing) ── - ServerSettingsSection(onServerChanged: _loadData), - - const Divider(height: 32), - - // ── Identity ── - _buildIdentitySection(theme), - - const Divider(height: 32), - - // ── Drives ── - _buildDrivesSection(theme), - ], - ), - ), - ), - actions: [ - TextButton( - onPressed: _signOut, - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Sign out'), - ), - const Spacer(), - TextButton( - onPressed: () => Navigator.pop(context, false), - child: const Text('Done'), - ), - ], - ); - } - - // ── Sync Section ────────────────────────────────────────────────────── - - /// This device, always shown first — the browser Sync page leads with the - /// same card. It is the one device you are looking *from*. - Widget _buildThisDeviceCard(ThemeData theme) { - final isOnline = _peerId != null; - - return _deviceCard( - theme, - icon: Icons.phone_android, - title: _deviceName.isNotEmpty ? _deviceName : 'This device', - onTitleTap: () async { - final controller = TextEditingController(text: _deviceName); - final newName = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Device name'), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration( - hintText: 'Enter device name', - border: OutlineInputBorder(), - ), - onSubmitted: (v) => Navigator.pop(ctx, v.trim()), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel')), - TextButton( - onPressed: () => Navigator.pop(ctx, controller.text.trim()), - child: const Text('Save'), - ), - ], - ), - ); - if (newName != null && newName.isNotEmpty) { - await AtomicClient.setDeviceName(newName); - setState(() => _deviceName = newName); - } - }, - status: isOnline ? 'Online' : (_peerStarting ? 'Starting...' : 'Offline'), - statusColor: - isOnline ? Colors.green : theme.colorScheme.onSurfaceVariant, - details: [ - if (_peerId != null) - _miniDetail('Device ID', '${_peerId!.substring(0, 16)}...', - onCopy: () => _copyToClipboard(_peerId!, 'Device ID')), - if (_activeDrive != null) - _miniDetail( - 'Drive', - _driveNames[_activeDrive]?.isNotEmpty == true - ? _driveNames[_activeDrive]! - : '${_activeDrive!.substring(0, 16)}...'), - ], - ); - } - - Widget _deviceCard( - ThemeData theme, { - required IconData icon, - required String title, - required String status, - required Color statusColor, - List details = const [], - VoidCallback? onTitleTap, - }) { - return Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: theme.colorScheme.outlineVariant.withValues(alpha: 0.3)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon, size: 20, color: theme.colorScheme.onSurface), - const SizedBox(width: 8), - GestureDetector( - onTap: onTitleTap, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(title, - style: const TextStyle( - fontSize: 13, fontWeight: FontWeight.w600)), - if (onTitleTap != null) ...[ - const SizedBox(width: 4), - Icon(Icons.edit, - size: 12, color: theme.colorScheme.onSurfaceVariant), - ], - ], - ), - ), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text(status, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: statusColor)), - ), - ], - ), - if (details.isNotEmpty) ...[ - const SizedBox(height: 8), - ...details, - ], - ], - ), - ); - } - - Widget _miniDetail(String label, String value, {VoidCallback? onCopy}) { - return Padding( - padding: const EdgeInsets.only(top: 2), - child: Row( - children: [ - SizedBox( - width: 65, - child: Text(label, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.onSurfaceVariant)), - ), - Expanded( - child: Text(value, - style: const TextStyle(fontSize: 11), - overflow: TextOverflow.ellipsis), - ), - if (onCopy != null) - GestureDetector( - onTap: onCopy, - child: Text('Copy', - style: TextStyle( - fontSize: 10, - color: Theme.of(context).colorScheme.primary)), - ), - ], - ), - ); - } - - // ── Identity Section ───────────────────────────────────────────────── - - Widget _buildIdentitySection(ThemeData theme) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sectionTitle('Identity'), - if (_agent != null) ...[ - _miniDetail('Name', _agent!.name ?? 'Anonymous'), - _miniDetail('DID', '${_agent!.subject.substring(0, 24)}...', - onCopy: () => _copyToClipboard(_agent!.subject, 'DID')), - const SizedBox(height: 4), - OutlinedButton.icon( - icon: const Icon(Icons.key, size: 14), - label: const Text('Copy Secret', style: TextStyle(fontSize: 12)), - onPressed: () => _copyToClipboard(_agent!.secret, 'Secret'), - ), - ] else - Text('No agent', - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant)), - ], - ); - } - - // ── Drives Section ─────────────────────────────────────────────────── - - Widget _buildDrivesSection(ThemeData theme) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _sectionTitle('Drives'), - if (_drives.isEmpty) - Text('No drives', - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onSurfaceVariant)) - else - ..._drives.map((d) => _driveTile(d)), - if (_showNewDrive) ...[ - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextField( - controller: _newDriveController, - autofocus: true, - decoration: const InputDecoration( - hintText: 'Drive name', - border: OutlineInputBorder(), - isDense: true, - contentPadding: - EdgeInsets.symmetric(horizontal: 12, vertical: 10), - ), - onSubmitted: (_) => _createDrive(), - ), - ), - const SizedBox(width: 8), - IconButton( - icon: _creatingDrive - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.check, size: 20), - onPressed: _creatingDrive ? null : _createDrive, - ), - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: () => setState(() => _showNewDrive = false), - ), - ], - ), - ] else - TextButton.icon( - icon: const Icon(Icons.add, size: 14), - label: const Text('New drive', style: TextStyle(fontSize: 12)), - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, - padding: const EdgeInsets.symmetric(horizontal: 4), - ), - onPressed: () => setState(() => _showNewDrive = true), - ), - ], - ); - } - - // ── Helpers ────────────────────────────────────────────────────────── - - Widget _driveTile(String drive) { - final isActive = drive == _activeDrive; - final name = _driveNames[drive]; - final label = (name != null && name.isNotEmpty) - ? name - : (drive.length > 30 - ? '${drive.substring(0, 12)}...${drive.substring(drive.length - 8)}' - : drive); - return ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: Icon( - isActive ? Icons.check_circle : Icons.circle_outlined, - color: isActive ? Theme.of(context).colorScheme.primary : Colors.grey, - size: 20, - ), - title: Text(label, style: const TextStyle(fontSize: 13)), - onTap: () => _switchDrive(drive), - ); - } - - Widget _sectionTitle(String title) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - title, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ); - } + Widget build(BuildContext context) => + shared.AgentSettingsDialog(backend: CanvasSettingsBackend()); } diff --git a/flutter/lib/atomic/widgets/server_settings_section.dart b/flutter/lib/atomic/widgets/server_settings_section.dart index fe606558c9..07257f9efa 100644 --- a/flutter/lib/atomic/widgets/server_settings_section.dart +++ b/flutter/lib/atomic/widgets/server_settings_section.dart @@ -1,594 +1,14 @@ -/// Server settings: which server this device syncs through, and what it costs. -/// -/// A sync hub is optional here — the data lives locally and syncs peer-to-peer -/// just as well — so "no server" is a first-class state, not an error. -/// -/// Mirrors the data-browser's Sync page: one stable list, the active server -/// marked rather than moved (a list that reorders under the finger that tapped -/// it is a bad list), URLs that do not demand a scheme, and the node's own -/// account of itself read from `/server`. -library; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../../screens/pair_screen.dart'; -import '../atomic_auth.dart'; -import '../atomic_client.dart'; -import '../server_info.dart'; -import '../server_url.dart'; -import '../session.dart'; +import 'package:atomic_flutter/atomic_flutter.dart' as shared; +import '../settings_backend.dart'; -class ServerSettingsSection extends StatefulWidget { +class ServerSettingsSection extends StatelessWidget { const ServerSettingsSection({super.key, this.onServerChanged}); - - /// Called after the active server changes, so the host can reload whatever - /// it read from the old one. final VoidCallback? onServerChanged; @override - State createState() => _ServerSettingsSectionState(); -} - -class _ServerSettingsSectionState extends State { - List _servers = []; - String? _active; - ServerInfo _info = ServerInfo.unknown; - DriveUsage? _usage; - // Paired devices (Iroh peers) share this one Devices list with servers — a - // server is just an always-on device, and the browser lists them together. - List> _peers = []; - Set _livePeerIds = {}; - bool _loading = true; - bool _busy = false; - bool _showAdd = false; - String? _error; - final _addController = TextEditingController(); - - @override - void initState() { - super.initState(); - _load(); - } - - @override - void dispose() { - _addController.dispose(); - super.dispose(); - } - - Future _load() async { - final servers = await AtomicSession.knownServers(); - final active = await AtomicSession.activeServer(); - - // Peers come from the Rust side; tolerate its absence (widget tests, a - // not-yet-initialized bridge) by showing the servers alone rather than - // failing the whole list. - List> peers = []; - Set livePeerIds = {}; - try { - peers = await AtomicClient.getKnownPeers(); - livePeerIds = AtomicClient.livePeerIds(); - } catch (_) { - // no peers available - } - - if (!mounted) return; - - setState(() { - _servers = servers; - _active = active; - _peers = peers; - _livePeerIds = livePeerIds; - _loading = false; - }); - - await _loadActiveDetails(); - } - - /// The active node's own account of itself, plus what this drive costs there. - /// Both are best-effort: an unreachable server is a normal state to be in. - Future _loadActiveDetails() async { - final active = _active; - - if (active == null) { - if (mounted) setState(() => _info = ServerInfo.unknown); - - return; - } - - final info = await fetchServerInfo(active); - - if (!mounted) return; - - setState(() => _info = info); - - // Usage is a signed read, so there is nothing to ask without an agent. - final session = await AtomicSession.load(); - - if (session == null) return; - - final drive = AtomicClient.getActiveDrive() ?? session.drive; - - if (drive == null) return; - - final usage = await fetchDriveUsage( - active, - drive, - AtomicAgent.fromSecret(session.secret), - ); - - if (mounted) setState(() => _usage = usage); - } - - Future _switchTo(String url) async { - setState(() { - _busy = true; - _error = null; - _usage = null; - _info = ServerInfo.unknown; - }); - - try { - await AtomicClient.closeWsSync(); - await AtomicSession.setActiveServer(url); - await AtomicClient.openWsSync(url); - - if (!mounted) return; - - setState(() => _active = normalizeServerUrl(url)); - widget.onServerChanged?.call(); - await _loadActiveDetails(); - } catch (e) { - // The server stays selected: it is the one the session now points at, and - // saying so beats silently snapping back to the old one. - if (mounted) setState(() => _error = 'Could not connect: $e'); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - Future _add() async { - final url = normalizeServerUrl(_addController.text); - - if (url.isEmpty) return; - - await AtomicSession.addKnownServer(url); - _addController.clear(); - - if (!mounted) return; - - setState(() => _showAdd = false); - - // First server added is the one to use — nothing to choose between. - if (_active == null) { - await _switchTo(url); - await _load(); - - return; - } - - await _load(); - } - - /// Offer this device's workspace to the active server. - /// - /// Connecting alone does not do this: a drive made here before any server was - /// connected has never been pushed, so it exists nowhere else — and a browser - /// signed in as the same account still sees nothing, because a browser reads - /// from a server rather than syncing with devices. - Future _pushWorkspace() async { - final active = _active; - - if (active == null || _busy) return; - - setState(() { - _busy = true; - _error = null; - }); - - try { - final pushed = await AtomicClient.syncDriveToServer(active); - - if (!mounted) return; - - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - pushed == 0 - ? 'Already up to date' - : 'Synced $pushed ${pushed == 1 ? 'resource' : 'resources'} to ${serverLabel(active)}', - ), - ), - ); - - await _loadActiveDetails(); - } catch (e) { - if (mounted) setState(() => _error = '$e'); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - Future _remove(String url) async { - final wasActive = sameOrigin(url, _active); - - if (wasActive) { - await AtomicClient.closeWsSync(); - } - - await AtomicSession.removeKnownServer(url); - - if (wasActive) widget.onServerChanged?.call(); - - await _load(); - } - - void _copy(String text, String label) { - Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$label copied'), duration: const Duration(seconds: 2)), - ); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - if (_loading) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center(child: CircularProgressIndicator()), + Widget build(BuildContext context) => shared.ServerSettingsSection( + backend: CanvasSettingsBackend(), + onServerChanged: onServerChanged, ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - 'Devices', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - if (_servers.isEmpty && _peers.isEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - 'No other devices yet. Pair one below, or add an always-on device by address.', - style: TextStyle( - fontSize: 12, - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ), - for (final server in _servers) _serverCard(theme, server), - for (final peer in _peers) _peerCard(theme, peer), - if (_error != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - _error!, - style: const TextStyle(fontSize: 12, color: Colors.red), - ), - ), - if (_showAdd) _addField(theme) else _addButton(), - ], - ); - } - - Widget _serverCard(ThemeData theme, String server) { - final isActive = sameOrigin(server, _active); - final scheme = theme.colorScheme; - - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - color: isActive ? scheme.primaryContainer.withValues(alpha: 0.3) : null, - border: Border.all( - color: isActive ? scheme.primary : scheme.outlineVariant, - width: isActive ? 1.5 : 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - isActive ? Icons.cloud_done : Icons.cloud_outlined, - size: 18, - color: isActive ? scheme.primary : scheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - serverLabel(server), - style: TextStyle( - fontSize: 14, - fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (isActive) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: scheme.primary, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - 'In use', - style: TextStyle(fontSize: 10, color: scheme.onPrimary), - ), - ), - ], - ), - if (isActive) ..._activeDetails(theme), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (isActive) - TextButton( - onPressed: _busy ? null : _pushWorkspace, - child: const Text( - 'Sync workspace here', - style: TextStyle(fontSize: 12), - ), - ), - if (!isActive) - TextButton( - onPressed: _busy ? null : () => _switchTo(server), - child: const Text('Switch to this', style: TextStyle(fontSize: 12)), - ), - TextButton( - onPressed: _busy ? null : () => _remove(server), - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Remove', style: TextStyle(fontSize: 12)), - ), - ], - ), - ], - ), - ); - } - - /// What the active node says about itself. Absent facts are simply not shown: - /// a node with no peer-to-peer transport has no node id, which is not a fault. - List _activeDetails(ThemeData theme) { - final scheme = theme.colorScheme; - final labelStyle = TextStyle(fontSize: 11, color: scheme.onSurfaceVariant); - - if (_busy) { - return const [ - SizedBox(height: 8), - SizedBox( - height: 2, - child: LinearProgressIndicator(), - ), - ]; - } - - return [ - const SizedBox(height: 6), - if (_info.version != null) - Text('atomic-server ${_info.version}', style: labelStyle) - else - Text('Not reachable', style: labelStyle.copyWith(color: Colors.orange)), - if (_info.nodeId != null) ...[ - const SizedBox(height: 4), - InkWell( - onTap: () => _copy(_info.nodeId!, 'Node ID'), - child: Row( - children: [ - Expanded( - child: Text( - _info.nodeId!, - style: const TextStyle(fontSize: 10, fontFamily: 'monospace'), - overflow: TextOverflow.ellipsis, - ), - ), - Icon(Icons.copy, size: 12, color: scheme.onSurfaceVariant), - ], - ), - ), - ], - if (_usage != null) ...[ - const SizedBox(height: 6), - Text( - '${_usage!.resourceCount} resources · ${formatBytes(_usage!.totalBytes)}', - style: labelStyle, - ), - if (_usage!.blobBytes > 0) - Text( - 'files ${formatBytes(_usage!.blobBytes)} · edits ${formatBytes(_usage!.loroBytes)}', - style: labelStyle.copyWith(fontSize: 10), - ), - ], - ]; - } - - /// A paired device (Iroh peer), shown alongside the always-on ones. Live - /// means it holds a connection right now; the green dot mirrors the browser. - /// Below the name we show the node id (copyable, so it can be pasted into - /// another device's "Connect by address") and when we last synced. - Widget _peerCard(ThemeData theme, Map peer) { - final nodeId = peer['node_id'] ?? ''; - final name = (peer['name'] ?? '').trim(); - final label = name.isNotEmpty - ? name - : '${nodeId.substring(0, nodeId.length.clamp(0, 12))}…'; - final isLive = AtomicClient.isLivePeer(nodeId, _livePeerIds); - final scheme = theme.colorScheme; - final labelStyle = TextStyle(fontSize: 11, color: scheme.onSurfaceVariant); - final lastSynced = int.tryParse(peer['last_synced'] ?? ''); - - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: scheme.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.phone_android, - size: 18, color: scheme.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Text( - label, - style: const TextStyle(fontSize: 14), - overflow: TextOverflow.ellipsis, - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: isLive - ? Colors.green.withValues(alpha: 0.15) - : scheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - isLive ? 'Connected' : 'Paired', - style: TextStyle( - fontSize: 10, - color: - isLive ? Colors.green.shade800 : scheme.onSurfaceVariant, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close, size: 16), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - tooltip: 'Remove', - onPressed: () async { - await AtomicClient.removeKnownPeer(nodeId); - setState( - () => _peers.removeWhere((p) => p['node_id'] == nodeId)); - }, - ), - ], - ), - if (nodeId.isNotEmpty) ...[ - const SizedBox(height: 6), - InkWell( - onTap: () => _copy(nodeId, 'Device ID'), - child: Row( - children: [ - Expanded( - child: Text( - nodeId, - style: - const TextStyle(fontSize: 10, fontFamily: 'monospace'), - overflow: TextOverflow.ellipsis, - ), - ), - Icon(Icons.copy, size: 12, color: scheme.onSurfaceVariant), - ], - ), - ), - ], - const SizedBox(height: 4), - Text( - lastSynced != null - ? 'Last synced ${_relativeTime(lastSynced)}' - : 'Not synced yet', - style: labelStyle, - ), - ], - ), - ); - } - - /// Coarse "2m ago" / "3h ago" / "5d ago" for a unix-millis timestamp — enough - /// to tell "just now" from "days back" without a date library. - String _relativeTime(int millis) { - final delta = DateTime.now().difference( - DateTime.fromMillisecondsSinceEpoch(millis)); - - if (delta.inSeconds < 60) return 'just now'; - if (delta.inMinutes < 60) return '${delta.inMinutes}m ago'; - if (delta.inHours < 24) return '${delta.inHours}h ago'; - - return '${delta.inDays}d ago'; - } - - /// Show a QR code for another device to scan, then reload so the freshly - /// paired device appears in the list above. - Future _pairWithQr() async { - final result = await PairScreen.show(context); - - if (result != null && mounted) await _load(); - } - - Widget _addButton() { - return Row( - children: [ - TextButton.icon( - onPressed: _pairWithQr, - icon: const Icon(Icons.qr_code_2, size: 16), - label: const Text('Pair with QR code', style: TextStyle(fontSize: 12)), - style: TextButton.styleFrom(padding: EdgeInsets.zero), - ), - const SizedBox(width: 8), - TextButton.icon( - onPressed: () => setState(() => _showAdd = true), - icon: const Icon(Icons.add, size: 16), - label: - const Text('Connect by address', style: TextStyle(fontSize: 12)), - style: TextButton.styleFrom(padding: EdgeInsets.zero), - ), - ], - ); - } - - Widget _addField(ThemeData theme) { - return Padding( - padding: const EdgeInsets.only(top: 4), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _addController, - autofocus: true, - decoration: const InputDecoration( - hintText: 'localhost:9883', - border: OutlineInputBorder(), - isDense: true, - ), - keyboardType: TextInputType.url, - autocorrect: false, - onSubmitted: (_) => _add(), - ), - ), - const SizedBox(width: 8), - TextButton( - onPressed: _add, - child: const Text('Add'), - ), - TextButton( - onPressed: () => setState(() { - _showAdd = false; - _addController.clear(); - }), - child: const Text('Cancel'), - ), - ], - ), - ); - } } diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index b50885e584..076a7aa507 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -25,6 +25,13 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + atomic_flutter: + dependency: "direct main" + description: + path: "../packages/atomic_flutter" + relative: true + source: path + version: "0.1.0" boolean_selector: dependency: transitive description: @@ -81,6 +88,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + cryptography: + dependency: transitive + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://pub.dev" + source: hosted + version: "2.9.0" cupertino_icons: dependency: "direct main" description: @@ -134,6 +149,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_lints: dependency: "direct dev" description: @@ -208,6 +228,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" http: dependency: "direct main" description: @@ -224,6 +249,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" js: dependency: transitive description: @@ -368,6 +398,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + process: + dependency: transitive + description: + name: process + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" + url: "https://pub.dev" + source: hosted + version: "5.0.6" qr: dependency: transitive description: @@ -484,6 +522,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.0" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -532,6 +578,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" + url: "https://pub.dev" + source: hosted + version: "3.2.0" win32: dependency: transitive description: @@ -550,4 +604,4 @@ packages: version: "1.1.0" sdks: dart: ">=3.10.0-0 <4.0.0" - flutter: ">=3.22.0" + flutter: ">=3.44.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 1741895c99..37649591a1 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -9,6 +9,8 @@ environment: sdk: ">=3.4.0 <4.0.0" dependencies: + atomic_flutter: + path: ../packages/atomic_flutter flutter: sdk: flutter cupertino_icons: ^1.0.8 @@ -24,6 +26,8 @@ dependencies: mobile_scanner: ^6.0.0 dev_dependencies: + integration_test: + sdk: flutter flutter_test: sdk: flutter flutter_lints: ^4.0.0 diff --git a/flutter/web_proxy.dart b/flutter/web_proxy.dart index ee3619a4b8..6532405186 100644 --- a/flutter/web_proxy.dart +++ b/flutter/web_proxy.dart @@ -1,3 +1,5 @@ +// Command-line proxy; stdout is its user interface. +// ignore_for_file: avoid_print // Reverse proxy that adds COOP/COEP headers for SharedArrayBuffer support. // Usage: dart run web_proxy.dart [proxy_port] diff --git a/lib/src/db.rs b/lib/src/db.rs index a5eff0c00e..567b22e04f 100644 --- a/lib/src/db.rs +++ b/lib/src/db.rs @@ -1318,20 +1318,32 @@ impl Db { let agent = self.get_default_agent()?; let agent_resource = self.get_resource(&agent.subject).await?; - let subjects = match agent_resource.get(urls::DRIVES) { + // Keep legacy Agent.drives entries, then include the private home where + // create_drive records new drives. Read both during the migration. + let mut subjects = match agent_resource.get(urls::DRIVES) { Ok(Value::ResourceArray(arr)) => arr.iter().map(|s| s.to_string()).collect::>(), _ => vec![], }; - - // Fallback: active drive not in agent resource - let subjects = if subjects.is_empty() { - match self.get_active_drive() { - Some(active) => vec![active], - None => vec![], + if let Ok(personal) = self.private_drive_subject() { + if let Ok(home) = self.get_resource(&personal.as_str().into()).await { + if !subjects.contains(&personal) { + subjects.push(personal); + } + if let Ok(Value::ResourceArray(listed)) = home.get(urls::DRIVES) { + for subject in listed { + let subject = subject.to_string(); + if !subjects.contains(&subject) { + subjects.push(subject); + } + } + } } - } else { - subjects - }; + } + if let Some(active) = self.get_active_drive() { + if !subjects.contains(&active) { + subjects.push(active); + } + } let mut drives = Vec::with_capacity(subjects.len()); for subject in subjects { @@ -5387,5 +5399,12 @@ mod private_drive_tests { listed.iter().any(|s| s == &extra), "private drive should list {extra}, got {listed:?}" ); + store.set_active_drive(&personal).unwrap(); + let drives = store.list_drives().await.unwrap(); + assert_eq!(drives.len(), 2); + assert!(drives.iter().any(|d| d.subject == personal)); + assert!(drives + .iter() + .any(|d| d.subject == extra && d.name == "Project")); } } diff --git a/lib/src/sync/peer.rs b/lib/src/sync/peer.rs index b86f850338..05d41e0f3d 100644 --- a/lib/src/sync/peer.rs +++ b/lib/src/sync/peer.rs @@ -1426,7 +1426,8 @@ fn register_live_peer( // this one (security audit C16). The WS // announcer ignores it, so the local browser // still sees the merged state. - let _ = super::ws_apply::import_scope( + let admitted_drive = resolved.drive_subject.clone(); + let persisted = super::ws_apply::import_scope( Some(read_peer_id.clone()), super::ws_apply::persist_update( &store, @@ -1435,6 +1436,29 @@ fn register_live_peer( ), ) .await; + // A File introduced during a live link needs + // the same blob request as a bulk SYNC_PUSH. Request + // only after admission and successful persistence, + // and only from the peer that supplied this resource. + if persisted.is_ok() { + let subject = crate::Subject::from_raw(&decoded.subject, store.get_base_domain().as_deref()); + if let Ok(resource) = store.get_resource(&subject).await { + if let Ok(value) = resource.get(crate::urls::BLOB) { + let blob = crate::Subject::from_raw(&value.to_string(), None); + if let Some(hash_hex) = blob.blob_hash_hex() { + if let Ok(bytes) = hex::decode(hash_hex) { + if let Ok(hash) = <[u8; 32]>::try_from(bytes) { + if !store.has_blob(&hash).await.unwrap_or(false) { + store.note_pending_blob_request(hash, admitted_drive); + let frame = super::protocol::encode_blob_request(&hash); + let _ = tx_for_read.send(frame_with_len(&frame)).await; + } + } + } + } + } + } + } tracing::trace!( "[live] imported update for {} from {}", &decoded.subject[..decoded.subject.len().min(20)], diff --git a/packages/atomic_flutter/.gitignore b/packages/atomic_flutter/.gitignore new file mode 100644 index 0000000000..aec3808998 --- /dev/null +++ b/packages/atomic_flutter/.gitignore @@ -0,0 +1,4 @@ +.dart_tool/ +build/ +coverage/ +pubspec.lock diff --git a/packages/atomic_flutter/CHANGELOG.md b/packages/atomic_flutter/CHANGELOG.md new file mode 100644 index 0000000000..b8972761b0 --- /dev/null +++ b/packages/atomic_flutter/CHANGELOG.md @@ -0,0 +1,5 @@ +## 0.1.0 + +* Extract Canvas AgentSettingsDialog and ServerSettingsSection. +* Add backend interfaces for identities, devices, drives and optional servers. +* Keep Canvas consuming the shared package through its existing entry points. diff --git a/packages/atomic_flutter/LICENSE b/packages/atomic_flutter/LICENSE new file mode 100644 index 0000000000..289426fa8e --- /dev/null +++ b/packages/atomic_flutter/LICENSE @@ -0,0 +1,33 @@ +MIT License + +Copyright (c) 2020 Joep Meindertsma + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +TRADEMARK NOTICE + +The licence above covers the Software. It does not grant permission to use +the trade names, trademarks, service marks, logos or product names of the +Atomic projects or of Ontola.io, except as required for reasonable and +customary use in describing the origin of the Software. + +The brand assets under `brand/` are licensed separately — see `brand/LICENSE` +— and the full policy is in `TRADEMARKS.md`. diff --git a/packages/atomic_flutter/README.md b/packages/atomic_flutter/README.md new file mode 100644 index 0000000000..0ce3c4ff1e --- /dev/null +++ b/packages/atomic_flutter/README.md @@ -0,0 +1,43 @@ +# atomic_flutter + +The account, device sync and drive dialog extracted from Atomic Canvas. Both +Canvas and Atomic Audio use `AgentSettingsDialog` with a host backend. + +```dart +await AgentSettingsDialog.show(context, backend: settingsBackend); +``` + +Implement `AtomicSettingsBackend` using your Atomic client. The host owns +identity storage, native bindings, pairing, drive persistence and application +state transitions. The package owns the existing Canvas dialog and Devices UI. +Secrets are fetched only when Copy Secret is pressed. `servers`, `signIn` and +`signOut` are optional capabilities; their actions appear only when supplied. +`switchDrive` must persist selection and switch the host's project state before +returning. Failures must throw, not be swallowed by the host. + +The package uses Flutter only, with no native bridge or app import. It is not +yet published on pub.dev. Consume via a Git dependency pinned to a revision, +with `path: packages/atomic_flutter`, or via a local path during development. + +Canvas's adapter is `flutter/lib/atomic/settings_backend.dart`. Pairing and +secure sign-in remain host adapters in this first extraction. A future package +can extract those implementations without coupling this UI to a native ABI. + +Run `flutter analyze` and `flutter test` in this directory. + +## Hosted accounts + +`AtomicAccountClient` implements the provider device-code flow, origin-bound +bearer sessions, account/device/hosting discovery, and AES-GCM assisted recovery. +It has no default provider and makes no request until called. The host owns +credential storage and must verify the recovered subject against the key before +installing it. Secrets are never part of an approval URL. + +`AtomicAccountLinkDialog` handles approval and cancellation. Supply a URL launcher +and an installation callback. `AtomicSettingsBackend.signInWithAccount`, +`accountStatus`, `syncAccount`, and `useLocalProjects` expose this in the shared +user dialog. Existing manual-secret and peer-only hosts remain supported. + +A provider session alone does not prove a workspace is synced. This package does +not implement Cloud Vault transport; hosts must connect their Atomic runtime to +the discovered Cloud Server or peer and verify received data. diff --git a/packages/atomic_flutter/analysis_options.yaml b/packages/atomic_flutter/analysis_options.yaml new file mode 100644 index 0000000000..f9b303465f --- /dev/null +++ b/packages/atomic_flutter/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flutter_lints/flutter.yaml diff --git a/packages/atomic_flutter/lib/atomic_flutter.dart b/packages/atomic_flutter/lib/atomic_flutter.dart new file mode 100644 index 0000000000..5088cf8120 --- /dev/null +++ b/packages/atomic_flutter/lib/atomic_flutter.dart @@ -0,0 +1,8 @@ +export 'src/settings_backend.dart'; +export 'src/agent_settings_dialog.dart'; +export 'src/server_settings_section.dart'; +export 'src/server_info.dart'; +export 'src/server_url.dart'; + +export 'src/account/client.dart'; +export 'src/account/link_dialog.dart'; diff --git a/packages/atomic_flutter/lib/src/account/client.dart b/packages/atomic_flutter/lib/src/account/client.dart new file mode 100644 index 0000000000..0b2c4dbddc --- /dev/null +++ b/packages/atomic_flutter/lib/src/account/client.dart @@ -0,0 +1,247 @@ +import 'dart:convert'; +import 'package:cryptography/cryptography.dart'; +import 'package:http/http.dart' as http; + +/// Explicit, provider-neutral account linking. Constructing this client never +/// makes a network request. Credentials stay bound to one HTTPS origin. +class AtomicAccountClient { + AtomicAccountClient(String provider, {http.Client? client}) + : origin = providerOrigin(provider), + _http = client ?? http.Client(); + final Uri origin; + final http.Client _http; + String? _token; + bool get linked => _token != null; + + static Uri providerOrigin(String input) { + final uri = Uri.tryParse(input); + if (uri == null || + uri.scheme != 'https' || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + (uri.path.isNotEmpty && uri.path != '/')) { + throw ArgumentError('An account provider must be an HTTPS origin'); + } + return uri.replace(path: '', query: null, fragment: null); + } + + /// Only the host credential store should persist this value, never UI/logs. + String exportSession() => + jsonEncode({'origin': origin.toString(), 'token': _token}); + void restoreSession(String saved) { + final data = jsonDecode(saved) as Map; + if (providerOrigin(data['origin'] as String) != origin || + data['token'] is! String || + (data['token'] as String).isEmpty) { + throw StateError('This session belongs to another provider'); + } + _token = data['token'] as String; + } + + void forgetSession() => _token = null; + void close() => _http.close(); + + Future _request(String method, String path, + {Map? body, bool authenticated = true}) async { + if (authenticated && _token == null) throw StateError('Sign in first'); + final req = http.Request(method, origin.replace(path: '/api/$path')) + ..followRedirects = false + ..headers['Accept'] = 'application/json'; + if (authenticated) req.headers['Authorization'] = 'Bearer $_token'; + if (body != null) { + req.headers['Content-Type'] = 'application/json'; + req.body = jsonEncode(body); + } + try { + return await (() async => + http.Response.fromStream(await _http.send(req)))() + .timeout(const Duration(seconds: 20)); + } catch (_) { + // Network exceptions can contain the polling URL, including device_code. + throw StateError('Could not reach the account provider'); + } + } + + dynamic _decode(String body) { + try { + return jsonDecode(body); + } catch (_) { + throw const FormatException('Invalid account response'); + } + } + + Map _object(http.Response response) { + if (response.statusCode < 200 || response.statusCode >= 300) { + if (response.statusCode == 401) { + throw StateError('Sign in again to continue'); + } + if (response.statusCode == 429) { + throw StateError('Too many attempts. Try again shortly'); + } + throw StateError('Account request failed (${response.statusCode})'); + } + final value = _decode(response.body); + if (value is! Map) { + throw const FormatException('Invalid account response'); + } + return value; + } + + Future requestLink(String deviceName) async { + final data = _object(await _request('POST', 'device-link', + authenticated: false, body: {'device_name': deviceName})); + return AtomicDeviceLink.fromJson(data, origin); + } + + Future pollLink(AtomicDeviceLink request) async { + if (request.origin != origin) { + throw StateError('Link belongs to another provider'); + } + if (DateTime.now().isAfter(request.expiresAt)) { + return AtomicLinkProgress.expired; + } + final response = await _request( + 'GET', 'device-link/${Uri.encodeComponent(request._deviceCode)}', + authenticated: false); + if (response.statusCode == 404) return AtomicLinkProgress.expired; + final body = _object(response); + if (body['state'] == 'approved') { + final token = body['token']; + if (token is! String || token.isEmpty) { + throw const FormatException('Missing account session'); + } + _token = token; + return AtomicLinkProgress.approved; + } + if (body['state'] != 'pending') { + throw const FormatException('Invalid link state'); + } + return AtomicLinkProgress.pending; + } + + Future> account() async => + _object(await _request('GET', 'me')); + + /// Recover the existing identity; never creates or replaces a user's backup. + /// Passkey/code-only accounts must use their existing manual recovery path. + Future recoverIdentity() async { + final me = await account(); + final response = await _request('GET', 'recovery-secret'); + if (response.statusCode == 404 || response.statusCode == 204) { + throw StateError('Finish setting up your workspace in the browser first'); + } + final data = _object(response); + if (data['owner_email'] != me['email']) { + throw StateError('Account backup belongs to another account'); + } + if (data['format_version'] != 2 || + data['encryption_algorithm'] != 'AES-GCM') { + throw StateError('This backup needs manual recovery in the browser'); + } + final wrappers = (data['wrappers'] as List?) ?? []; + final wrapper = wrappers + .whereType>() + .where((w) => w['wrapper_type'] == 'atomic-assisted') + .firstOrNull; + if (wrapper == null) { + throw StateError( + 'Use your recovery code or passkey in the browser, then import your Atomic secret'); + } + final keyResponse = + await _request('POST', 'recovery-secret/assisted-key', body: { + 'agent_subject': data['agent_subject'], + 'salt': wrapper['salt'], + }); + if (keyResponse.statusCode == 403) { + throw StateError('Sign in again in the browser to unlock this device'); + } + final key = _object(keyResponse); + final secret = + await openAssistedEnvelope(data, wrapper, key['key'] as String); + return AtomicRecoveredIdentity( + subject: data['agent_subject'] as String, + secret: secret, + address: (me['address'] ?? me['email']) as String, + drive: data['drive_subject'] as String?, + ); + } + + Future>> _list(String path, String key) async { + final response = await _request('GET', path); + if (response.statusCode != 200) { + _object(response); + } + final data = _decode(response.body); + final list = data is List + ? data + : data is Map + ? data[key] + : null; + if (list is! List) throw const FormatException('Invalid account list'); + return list.map((e) => Map.from(e as Map)).toList(); + } + + Future>> devices() => _list('devices', 'devices'); + Future>> enrollments() => + _list('sync-enrollments', 'enrollments'); + + /// Pure authenticated decryption, compatible with the browser's WebCrypto + /// envelope-v2. AES-GCM stores the 16-byte tag after the ciphertext. + static Future openAssistedEnvelope(Map envelope, + Map wrapper, String encodedKey) async { + Future> open(String cipher, String nonce, List key) async { + final bytes = base64Decode(cipher); + final iv = base64Decode(nonce); + if (bytes.length < 16 || iv.length != 12 || key.length != 32) { + throw const FormatException('Invalid encrypted identity'); + } + return AesGcm.with256bits().decrypt( + SecretBox(bytes.sublist(0, bytes.length - 16), + nonce: iv, mac: Mac(bytes.sublist(bytes.length - 16))), + secretKey: SecretKey(key)); + } + + try { + final dek = await open(wrapper['wrapped_dek'] as String, + wrapper['wrap_nonce'] as String, base64Decode(encodedKey)); + return utf8.decode(await open(envelope['encrypted_secret'] as String, + envelope['nonce'] as String, dek)); + } catch (_) { + throw StateError('Your account could not unlock this backup'); + } + } +} + +enum AtomicLinkProgress { pending, approved, expired } + +class AtomicDeviceLink { + AtomicDeviceLink.fromJson(Map data, this.origin) + : _deviceCode = data['device_code'] as String, + userCode = data['user_code'] as String, + interval = Duration(seconds: (data['interval'] as int).clamp(1, 3600)), + expiresAt = DateTime.now().add( + Duration(seconds: (data['expires_in'] as int).clamp(1, 3600))) { + if (_deviceCode.isEmpty || userCode.isEmpty) { + throw const FormatException('Invalid device link'); + } + } + final Uri origin; + final String _deviceCode, userCode; + final Duration interval; + final DateTime expiresAt; + Uri get approvalUrl => + origin.replace(path: '/link', queryParameters: {'code': userCode}); +} + +/// Carries a credential: deliberately has no Debug/toString serialization. +class AtomicRecoveredIdentity { + AtomicRecoveredIdentity( + {required this.subject, + required this.secret, + required this.address, + this.drive}); + final String subject, secret, address; + final String? drive; +} diff --git a/packages/atomic_flutter/lib/src/account/link_dialog.dart b/packages/atomic_flutter/lib/src/account/link_dialog.dart new file mode 100644 index 0000000000..95ad374e15 --- /dev/null +++ b/packages/atomic_flutter/lib/src/account/link_dialog.dart @@ -0,0 +1,196 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'client.dart'; + +/// Shared browser approval UI. The host owns launching and credential storage. +/// Closing cancels polling; late replies never install a recovered identity. +class AtomicAccountLinkDialog extends StatefulWidget { + const AtomicAccountLinkDialog( + {super.key, + required this.client, + required this.deviceName, + required this.openUrl, + required this.install}); + final AtomicAccountClient client; + final String deviceName; + final Future Function(Uri) openUrl; + final Future Function(AtomicRecoveredIdentity, String session) install; + static Future show( + BuildContext context, { + required AtomicAccountClient client, + required String deviceName, + required Future Function(Uri) openUrl, + required Future Function(AtomicRecoveredIdentity, String) install, + }) async => + await showDialog( + context: context, + builder: (_) => AtomicAccountLinkDialog( + client: client, + deviceName: deviceName, + openUrl: openUrl, + install: install)) ?? + false; + @override + State createState() => _LinkState(); +} + +class _LinkState extends State { + AtomicDeviceLink? _request; + AtomicRecoveredIdentity? _identity; + String? _error; + Timer? _timer; + bool _busy = false; + int _generation = 0; + @override + void initState() { + super.initState(); + _start(); + } + + @override + void dispose() { + _generation++; + _timer?.cancel(); + super.dispose(); + } + + Future _start() async { + _timer?.cancel(); + final generation = ++_generation; + setState(() { + _error = null; + _request = null; + _identity = null; + _busy = true; + }); + try { + final request = await widget.client.requestLink(widget.deviceName); + if (!mounted || generation != _generation) return; + setState(() { + _request = request; + _busy = false; + }); + _timer = Timer(request.interval, () => _poll(generation)); + } catch (e) { + if (mounted && generation == _generation) { + setState(() { + _error = '$e'; + _busy = false; + }); + } + } + } + + Future _poll(int generation) async { + try { + final result = await widget.client.pollLink(_request!); + if (!mounted || generation != _generation) return; + switch (result) { + case AtomicLinkProgress.pending: + _timer = Timer(_request!.interval, () => _poll(generation)); + case AtomicLinkProgress.expired: + setState(() => + _error = 'This code expired. Start again to get a new one.'); + case AtomicLinkProgress.approved: + setState(() => _busy = true); + final identity = await widget.client.recoverIdentity(); + if (!mounted || generation != _generation) return; + setState(() { + _identity = identity; + _busy = false; + }); + } + } catch (e) { + if (mounted && generation == _generation) { + setState(() { + _error = '$e'; + _busy = false; + }); + } + } + } + + Future _install() async { + setState(() { + _busy = true; + _error = null; + }); + try { + await widget.install(_identity!, widget.client.exportSession()); + if (mounted) Navigator.pop(context, true); + } catch (e) { + if (mounted) { + setState(() { + _error = '$e'; + _busy = false; + }); + } + } + } + + @override + Widget build(BuildContext context) => PopScope( + canPop: !_busy || _identity == null, + child: AlertDialog( + title: const Text('Connect your account'), + content: SizedBox( + width: 380, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.client.origin.host), + const SizedBox(height: 16), + if (_busy) const LinearProgressIndicator(), + if (_identity case final identity?) ...[ + Text(identity.address, + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + const Text( + 'Open this account in the app. Your local projects will be kept separately.'), + ] else if (_request case final request?) ...[ + const Text( + 'Sign in or create your account in the browser, then approve this device.'), + const SizedBox(height: 16), + SelectableText(request.userCode, + style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: () async { + try { + await widget.openUrl(request.approvalUrl); + } catch (_) { + if (mounted) { + setState( + () => _error = 'Could not open the browser'); + } + } + }, + icon: const Icon(Icons.open_in_browser), + label: const Text('Open browser')), + const SizedBox(height: 8), + const Text('Waiting for browser approval…'), + ], + if (_error != null) ...[ + const SizedBox(height: 12), + Text(_error!, + style: TextStyle( + color: Theme.of(context).colorScheme.error)), + TextButton(onPressed: _start, child: const Text('Try again')), + ], + ], + ))), + actions: [ + TextButton( + onPressed: _busy && _identity != null + ? null + : () => Navigator.pop(context, false), + child: const Text('Cancel')), + if (_identity != null) + FilledButton( + onPressed: _busy ? null : _install, + child: const Text('Use this account')), + ], + )); +} diff --git a/packages/atomic_flutter/lib/src/agent_settings_dialog.dart b/packages/atomic_flutter/lib/src/agent_settings_dialog.dart new file mode 100644 index 0000000000..2de9debab8 --- /dev/null +++ b/packages/atomic_flutter/lib/src/agent_settings_dialog.dart @@ -0,0 +1,584 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'settings_backend.dart'; +import 'error_snack.dart'; +import 'server_settings_section.dart'; + +class AgentSettingsDialog extends StatefulWidget { + const AgentSettingsDialog({super.key, required this.backend}); + + final AtomicSettingsBackend backend; + + static Future show(BuildContext context, + {required AtomicSettingsBackend backend}) async { + final result = await showDialog( + context: context, + builder: (context) => AgentSettingsDialog(backend: backend), + ); + return result ?? false; + } + + @override + State createState() => _AgentSettingsDialogState(); +} + +class _AgentSettingsDialogState extends State { + AtomicIdentity? _agent; + String? _error; + List _drives = []; + Map _driveNames = {}; + String? _activeDrive; + bool _loading = true; + bool _creatingDrive = false; + bool _showNewDrive = false; + String? _peerId; + // The peer never starts from this dialog anymore (pairing is its own screen), + // but the "This device" card still reads it as an online/starting hint. + final bool _peerStarting = false; + String _deviceName = ''; + final _newDriveController = TextEditingController(); + + @override + void initState() { + super.initState(); + _loadData(); + } + + @override + void dispose() { + _newDriveController.dispose(); + super.dispose(); + } + + // ── Actions ────────────────────────────────────────────────────────── + + Future _loadData() async { + setState(() => _loading = true); + try { + final deviceName = await widget.backend.getDeviceName(); + final agent = await widget.backend.getActiveAgent(); + final drives = await widget.backend.listDrives(); + final activeDrive = widget.backend.getActiveDrive(); + final peerId = await widget.backend.getPeerId(); + + final names = {}; + for (final d in drives) { + try { + names[d] = await widget.backend.getDriveName(d); + } catch (_) { + names[d] = ''; + } + } + + if (!mounted) return; + setState(() { + _error = null; + _deviceName = deviceName; + _agent = agent; + _drives = drives; + _driveNames = names; + _activeDrive = activeDrive; + _peerId = peerId; + _loading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _error = '$e'; + _loading = false; + }); + } + } + } + + Future _createDrive() async { + final name = _newDriveController.text.trim(); + if (name.isEmpty) return; + setState(() => _creatingDrive = true); + try { + await widget.backend.createDrive(name); + if (!mounted) return; + _newDriveController.clear(); + setState(() => _showNewDrive = false); + await _loadData(); + } catch (e) { + if (mounted) showErrorSnack(context, 'Failed to create drive: $e'); + } + if (mounted) setState(() => _creatingDrive = false); + } + + Future _switchDrive(String drive) async { + try { + await widget.backend.switchDrive(drive); + if (!mounted) return; + setState(() => _activeDrive = drive); + } catch (e) { + if (mounted) showErrorSnack(context, 'Failed to switch drive: $e'); + } + } + + Future _signOut() async { + final navigator = Navigator.of(context); + final confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Sign out?'), + content: const Text( + 'Your local data will be kept, but you\'ll need your secret to sign back in.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Sign out'), + ), + ], + ), + ); + if (confirm != true) return; + try { + await widget.backend.signOut!(); + if (navigator.mounted) navigator.pop(true); + } catch (e) { + if (mounted) showErrorSnack(context, 'Could not sign out: $e'); + } + } + + void _copyToClipboard(String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$label copied'), duration: const Duration(seconds: 2)), + ); + } + + // ── Build ──────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final screenWidth = MediaQuery.of(context).size.width; + final isPhone = screenWidth < 600; + final dialogWidth = isPhone ? screenWidth * 0.92 : 420.0; + + return AlertDialog( + title: const Text('Settings'), + insetPadding: EdgeInsets.symmetric( + horizontal: isPhone ? 12 : 40, + vertical: 24, + ), + content: _loading + ? const SizedBox( + height: 200, child: Center(child: CircularProgressIndicator())) + : SizedBox( + width: dialogWidth, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // This device, then the devices it syncs with, then the + // code to add another — the same order as the browser Sync + // page. A server is one of those devices (an always-on + // one), not a category of its own. + if (_error != null) ...[ + Text(_error!, + style: TextStyle(color: theme.colorScheme.error)), + TextButton( + onPressed: _loadData, child: const Text('Retry')), + ], + if (widget.backend.signInWithAccount != null) ...[ + Text(widget.backend.accountStatus ?? + 'Connect your Atomic account'), + const SizedBox(height: 8), + Wrap(spacing: 8, children: [ + OutlinedButton.icon( + icon: const Icon(Icons.account_circle_outlined), + label: const Text('Sign in with account'), + onPressed: () async { + try { + await widget + .backend.signInWithAccount!(context); + await _loadData(); + } catch (e) { + if (context.mounted) { + showErrorSnack(context, '$e'); + } + } + }), + if (widget.backend.syncAccount != null) + OutlinedButton( + onPressed: () async { + try { + await widget.backend.syncAccount!(); + await _loadData(); + } catch (e) { + if (context.mounted) { + showErrorSnack(context, '$e'); + } + } + }, + child: const Text('Sync now')), + if (widget.backend.useLocalProjects != null) + TextButton( + onPressed: () async { + try { + await widget.backend.useLocalProjects!(); + await _loadData(); + } catch (e) { + if (context.mounted) { + showErrorSnack(context, '$e'); + } + } + }, + child: const Text('Local projects')), + ]), + const Divider(height: 32), + ], + _buildThisDeviceCard(theme), + + const SizedBox(height: 16), + + // ── Devices (servers + paired devices, incl. QR pairing) ── + ServerSettingsSection( + backend: widget.backend, onServerChanged: _loadData), + + const Divider(height: 32), + + // ── Identity ── + _buildIdentitySection(theme), + + const Divider(height: 32), + + // ── Drives ── + _buildDrivesSection(theme), + ], + ), + ), + ), + actions: [ + if (widget.backend.signOut != null) + TextButton( + onPressed: _signOut, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Sign out'), + ), + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Done'), + ), + ], + ); + } + + // ── Sync Section ────────────────────────────────────────────────────── + + /// This device, always shown first — the browser Sync page leads with the + /// same card. It is the one device you are looking *from*. + Widget _buildThisDeviceCard(ThemeData theme) { + final isOnline = _peerId != null; + + return _deviceCard( + theme, + icon: Icons.phone_android, + title: _deviceName.isNotEmpty ? _deviceName : 'This device', + onTitleTap: () async { + final controller = TextEditingController(text: _deviceName); + final newName = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Device name'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Enter device name', + border: OutlineInputBorder(), + ), + onSubmitted: (v) => Navigator.pop(ctx, v.trim()), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx, controller.text.trim()), + child: const Text('Save'), + ), + ], + ), + ); + if (newName != null && newName.isNotEmpty) { + try { + await widget.backend.setDeviceName(newName); + if (mounted) setState(() => _deviceName = newName); + } catch (e) { + if (mounted) showErrorSnack(context, 'Could not rename device: $e'); + } + } + }, + status: isOnline ? 'Online' : (_peerStarting ? 'Starting...' : 'Offline'), + statusColor: isOnline ? Colors.green : theme.colorScheme.onSurfaceVariant, + details: [ + if (_peerId != null) + _miniDetail('Device ID', _shortId(_peerId!, 16), + onCopy: () => _copyToClipboard(_peerId!, 'Device ID')), + if (_activeDrive != null) + _miniDetail( + 'Drive', + _driveNames[_activeDrive]?.isNotEmpty == true + ? _driveNames[_activeDrive]! + : _shortId(_activeDrive!, 16)), + ], + ); + } + + Widget _deviceCard( + ThemeData theme, { + required IconData icon, + required String title, + required String status, + required Color statusColor, + List details = const [], + VoidCallback? onTitleTap, + }) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: theme.colorScheme.outlineVariant.withValues(alpha: 0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: theme.colorScheme.onSurface), + const SizedBox(width: 8), + Expanded( + child: GestureDetector( + onTap: onTitleTap, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text(title, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w600))), + if (onTitleTap != null) ...[ + const SizedBox(width: 4), + Icon(Icons.edit, + size: 12, color: theme.colorScheme.onSurfaceVariant), + ], + ], + ), + )), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text(status, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: statusColor)), + ), + ], + ), + if (details.isNotEmpty) ...[ + const SizedBox(height: 8), + ...details, + ], + ], + ), + ); + } + + Widget _miniDetail(String label, String value, {VoidCallback? onCopy}) { + return Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + children: [ + SizedBox( + width: 65, + child: Text(label, + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + ), + Expanded( + child: Text(value, + style: const TextStyle(fontSize: 11), + overflow: TextOverflow.ellipsis), + ), + if (onCopy != null) + GestureDetector( + onTap: onCopy, + child: Text('Copy', + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.primary)), + ), + ], + ), + ); + } + + // ── Identity Section ───────────────────────────────────────────────── + + Widget _buildIdentitySection(ThemeData theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Identity'), + if (_agent != null) ...[ + _miniDetail('Name', _agent!.name ?? 'Anonymous'), + _miniDetail('DID', _shortId(_agent!.subject, 24), + onCopy: () => _copyToClipboard(_agent!.subject, 'DID')), + const SizedBox(height: 4), + OutlinedButton.icon( + icon: const Icon(Icons.key, size: 14), + label: const Text('Copy Secret', style: TextStyle(fontSize: 12)), + onPressed: () async { + try { + final secret = await widget.backend.exportSecret(); + if (mounted) _copyToClipboard(secret, 'Secret'); + } catch (e) { + if (mounted) { + showErrorSnack(context, 'Could not copy secret: $e'); + } + } + }, + ), + ] else + Text('No agent', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant)), + if (widget.backend.signIn != null) + TextButton.icon( + icon: const Icon(Icons.login, size: 14), + label: const Text('Sign in with a secret'), + onPressed: () async { + try { + await widget.backend.signIn!(context); + if (mounted) await _loadData(); + } catch (e) { + if (mounted) showErrorSnack(context, 'Could not sign in: $e'); + } + }), + ], + ); + } + + // ── Drives Section ─────────────────────────────────────────────────── + + Widget _buildDrivesSection(ThemeData theme) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionTitle('Drives'), + if (_drives.isEmpty) + Text('No drives', + style: TextStyle( + fontSize: 13, + color: Theme.of(context).colorScheme.onSurfaceVariant)) + else + ..._drives.map((d) => _driveTile(d)), + if (_showNewDrive) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: _newDriveController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Drive name', + border: OutlineInputBorder(), + isDense: true, + contentPadding: + EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + onSubmitted: (_) => _createDrive(), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: _creatingDrive + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.check, size: 20), + onPressed: _creatingDrive ? null : _createDrive, + ), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => setState(() => _showNewDrive = false), + ), + ], + ), + ] else + TextButton.icon( + icon: const Icon(Icons.add, size: 14), + label: const Text('New drive', style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, + padding: const EdgeInsets.symmetric(horizontal: 4), + ), + onPressed: () => setState(() => _showNewDrive = true), + ), + ], + ); + } + + String _shortId(String id, int length) => + id.length <= length ? id : '${id.substring(0, length)}...'; + + // ── Helpers ────────────────────────────────────────────────────────── + + Widget _driveTile(String drive) { + final isActive = drive == _activeDrive; + final name = _driveNames[drive]; + final label = (name != null && name.isNotEmpty) + ? name + : (drive.length > 30 + ? '${drive.substring(0, 12)}...${drive.substring(drive.length - 8)}' + : drive); + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: Icon( + isActive ? Icons.check_circle : Icons.circle_outlined, + color: isActive ? Theme.of(context).colorScheme.primary : Colors.grey, + size: 20, + ), + title: Text(label, style: const TextStyle(fontSize: 13)), + onTap: () => _switchDrive(drive), + ); + } + + Widget _sectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } +} diff --git a/packages/atomic_flutter/lib/src/error_snack.dart b/packages/atomic_flutter/lib/src/error_snack.dart new file mode 100644 index 0000000000..477689cdf0 --- /dev/null +++ b/packages/atomic_flutter/lib/src/error_snack.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Show an error that does NOT auto-dismiss: it stays until the user taps it +/// away (the close icon) and offers a Copy button. A sync or pairing failure +/// that used to flash past for a second can now be read and copied for a bug +/// report. +void showErrorSnack(BuildContext context, String message) { + final messenger = ScaffoldMessenger.of(context); + final theme = Theme.of(context); + + // Replace any earlier error rather than stacking them. + messenger.clearSnackBars(); + messenger.showSnackBar( + SnackBar( + content: Text( + message, + style: TextStyle(color: theme.colorScheme.onErrorContainer), + ), + // Effectively persistent — dismissed by the close icon or Copy, not a + // timer. (SnackBar has no true "forever", so use a long duration.) + duration: const Duration(minutes: 10), + showCloseIcon: true, + backgroundColor: theme.colorScheme.errorContainer, + closeIconColor: theme.colorScheme.onErrorContainer, + behavior: SnackBarBehavior.floating, + action: SnackBarAction( + label: 'Copy', + textColor: theme.colorScheme.onErrorContainer, + onPressed: () { + Clipboard.setData(ClipboardData(text: message)); + }, + ), + ), + ); +} diff --git a/packages/atomic_flutter/lib/src/server_info.dart b/packages/atomic_flutter/lib/src/server_info.dart new file mode 100644 index 0000000000..bf6aa285d6 --- /dev/null +++ b/packages/atomic_flutter/lib/src/server_info.dart @@ -0,0 +1,84 @@ +/// Property URLs of the `Server` class. Handwritten rather than generated — +/// this describes the node, not anything in a drive. Keep in step with +/// `lib/src/urls.rs` and the data-browser's `serverOntology.ts`. +class ServerProps { + static const nodeId = 'https://atomicdata.dev/properties/server/nodeId'; + static const version = 'https://atomicdata.dev/properties/server/version'; + static const managed = 'https://atomicdata.dev/properties/server/managed'; + static const portalUrl = 'https://atomicdata.dev/properties/server/portalUrl'; +} + +/// A node's own description. Fields are null when the node does not report +/// them: an older server, or one with no peer-to-peer transport running. +class ServerInfo { + const ServerInfo({ + this.nodeId, + this.version, + this.managed = false, + this.portalUrl, + }); + + /// This node's `did:ad:node:...` identity, if its p2p transport is running. + final String? nodeId; + final String? version; + + /// Whether the node reports to a control plane, rather than being self-hosted. + final bool managed; + + /// Where a managed node is administered. + final String? portalUrl; + + static const unknown = ServerInfo(); + + factory ServerInfo.fromJsonAd(Map json) { + String? read(String prop) { + final value = json[prop]; + + return value is String && value.isNotEmpty ? value : null; + } + + return ServerInfo( + nodeId: read(ServerProps.nodeId), + version: read(ServerProps.version), + managed: json[ServerProps.managed] == true, + portalUrl: read(ServerProps.portalUrl), + ); + } +} + +/// What a drive stores on a node. +class DriveUsage { + const DriveUsage({ + this.driveName, + required this.resourceCount, + required this.blobBytes, + required this.loroBytes, + }); + + final String? driveName; + final int resourceCount; + + /// Bytes held by file contents. + final int blobBytes; + + /// Bytes held by the CRDT documents behind the resources. + final int loroBytes; + + int get totalBytes => blobBytes + loroBytes; +} + +/// Bytes as a person reads them. +String formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + + const units = ['KB', 'MB', 'GB', 'TB']; + var value = bytes / 1024; + var unit = 0; + + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + + return '${value.toStringAsFixed(value < 10 ? 1 : 0)} ${units[unit]}'; +} diff --git a/packages/atomic_flutter/lib/src/server_settings_section.dart b/packages/atomic_flutter/lib/src/server_settings_section.dart new file mode 100644 index 0000000000..c2f8408aa5 --- /dev/null +++ b/packages/atomic_flutter/lib/src/server_settings_section.dart @@ -0,0 +1,615 @@ +/// Server settings: which server this device syncs through, and what it costs. +/// +/// A sync hub is optional here — the data lives locally and syncs peer-to-peer +/// just as well — so "no server" is a first-class state, not an error. +/// +/// Mirrors the data-browser's Sync page: one stable list, the active server +/// marked rather than moved (a list that reorders under the finger that tapped +/// it is a bad list), URLs that do not demand a scheme, and the node's own +/// account of itself read from `/server`. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'settings_backend.dart'; +import 'server_info.dart'; +import 'server_url.dart'; + +class ServerSettingsSection extends StatefulWidget { + const ServerSettingsSection( + {super.key, required this.backend, this.onServerChanged}); + + final AtomicSettingsBackend backend; + + /// Called after the active server changes, so the host can reload whatever + /// it read from the old one. + final VoidCallback? onServerChanged; + + @override + State createState() => _ServerSettingsSectionState(); +} + +class _ServerSettingsSectionState extends State { + List _servers = []; + String? _active; + ServerInfo _info = ServerInfo.unknown; + DriveUsage? _usage; + // Paired devices (Iroh peers) share this one Devices list with servers — a + // server is just an always-on device, and the browser lists them together. + List> _peers = []; + Set _livePeerIds = {}; + bool _loading = true; + bool _busy = false; + bool _showAdd = false; + String? _error; + final _addController = TextEditingController(); + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _addController.dispose(); + super.dispose(); + } + + Future _load() async { + try { + final servers = + await widget.backend.servers?.knownServers() ?? []; + final active = await widget.backend.servers?.activeServer(); + + // Peers come from the Rust side; tolerate its absence (widget tests, a + // not-yet-initialized bridge) by showing the servers alone rather than + // failing the whole list. + List> peers = []; + Set livePeerIds = {}; + try { + peers = await widget.backend.getKnownPeers(); + livePeerIds = await widget.backend.livePeerIds(); + } catch (_) { + // no peers available + } + + if (!mounted) return; + + setState(() { + _servers = servers; + _active = active; + _peers = peers; + _livePeerIds = livePeerIds; + _loading = false; + }); + + await _loadActiveDetails(); + } catch (e) { + if (mounted) { + setState(() { + _loading = false; + _error = '$e'; + }); + } + } + } + + /// The active node's own account of itself, plus what this drive costs there. + /// Both are best-effort: an unreachable server is a normal state to be in. + Future _loadActiveDetails() async { + final active = _active; + + if (active == null) { + if (mounted) setState(() => _info = ServerInfo.unknown); + + return; + } + + final info = await widget.backend.servers!.serverInfo(active); + + if (!mounted) return; + + setState(() => _info = info); + + final usage = await widget.backend.servers!.driveUsage(active); + + if (mounted) setState(() => _usage = usage); + } + + Future _switchTo(String url) async { + setState(() { + _busy = true; + _error = null; + _usage = null; + _info = ServerInfo.unknown; + }); + + try { + await widget.backend.servers!.switchTo(url); + + if (!mounted) return; + + setState(() => _active = normalizeServerUrl(url)); + widget.onServerChanged?.call(); + await _loadActiveDetails(); + } catch (e) { + // The server stays selected: it is the one the session now points at, and + // saying so beats silently snapping back to the old one. + if (mounted) setState(() => _error = 'Could not connect: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _add() async { + final url = normalizeServerUrl(_addController.text); + + if (url.isEmpty) return; + + await widget.backend.servers!.add(url); + _addController.clear(); + + if (!mounted) return; + + setState(() => _showAdd = false); + + // First server added is the one to use — nothing to choose between. + if (_active == null) { + await _switchTo(url); + await _load(); + + return; + } + + await _load(); + } + + /// Offer this device's workspace to the active server. + /// + /// Connecting alone does not do this: a drive made here before any server was + /// connected has never been pushed, so it exists nowhere else — and a browser + /// signed in as the same account still sees nothing, because a browser reads + /// from a server rather than syncing with devices. + Future _pushWorkspace() async { + final active = _active; + + if (active == null || _busy) return; + + setState(() { + _busy = true; + _error = null; + }); + + try { + final pushed = await widget.backend.servers!.pushWorkspace(active); + + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + pushed == 0 + ? 'Already up to date' + : 'Synced $pushed ${pushed == 1 ? 'resource' : 'resources'} to ${serverLabel(active)}', + ), + ), + ); + + await _loadActiveDetails(); + } catch (e) { + if (mounted) setState(() => _error = '$e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _runAction(Future Function() action) async { + if (_busy) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await action(); + } catch (e) { + if (mounted) setState(() => _error = '$e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _remove(String url) async { + final wasActive = sameOrigin(url, _active); + + await widget.backend.servers!.remove(url); + + if (wasActive) widget.onServerChanged?.call(); + + await _load(); + } + + void _copy(String text, String label) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$label copied'), duration: const Duration(seconds: 2)), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (_loading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'Devices', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + if (_servers.isEmpty && _peers.isEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + widget.backend.servers == null + ? 'No other devices yet. Pair one below.' + : 'No other devices yet. Pair one below, or add an always-on device by address.', + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + for (final server in _servers) _serverCard(theme, server), + for (final peer in _peers) _peerCard(theme, peer), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _error!, + style: const TextStyle(fontSize: 12, color: Colors.red), + ), + ), + if (_showAdd) _addField(theme) else _addButton(), + ], + ); + } + + Widget _serverCard(ThemeData theme, String server) { + final isActive = sameOrigin(server, _active); + final scheme = theme.colorScheme; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isActive ? scheme.primaryContainer.withValues(alpha: 0.3) : null, + border: Border.all( + color: isActive ? scheme.primary : scheme.outlineVariant, + width: isActive ? 1.5 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + isActive ? Icons.cloud_done : Icons.cloud_outlined, + size: 18, + color: isActive ? scheme.primary : scheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + serverLabel(server), + style: TextStyle( + fontSize: 14, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (isActive) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: scheme.primary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'In use', + style: TextStyle(fontSize: 10, color: scheme.onPrimary), + ), + ), + ], + ), + if (isActive) ..._activeDetails(theme), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (isActive) + TextButton( + onPressed: _busy ? null : _pushWorkspace, + child: const Text( + 'Sync workspace here', + style: TextStyle(fontSize: 12), + ), + ), + if (!isActive) + TextButton( + onPressed: _busy ? null : () => _switchTo(server), + child: const Text('Switch to this', + style: TextStyle(fontSize: 12)), + ), + TextButton( + onPressed: + _busy ? null : () => _runAction(() => _remove(server)), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Remove', style: TextStyle(fontSize: 12)), + ), + ], + ), + ], + ), + ); + } + + /// What the active node says about itself. Absent facts are simply not shown: + /// a node with no peer-to-peer transport has no node id, which is not a fault. + List _activeDetails(ThemeData theme) { + final scheme = theme.colorScheme; + final labelStyle = TextStyle(fontSize: 11, color: scheme.onSurfaceVariant); + + if (_busy) { + return const [ + SizedBox(height: 8), + SizedBox( + height: 2, + child: LinearProgressIndicator(), + ), + ]; + } + + return [ + const SizedBox(height: 6), + if (_info.version != null) + Text('atomic-server ${_info.version}', style: labelStyle) + else + Text('Not reachable', style: labelStyle.copyWith(color: Colors.orange)), + if (_info.nodeId != null) ...[ + const SizedBox(height: 4), + InkWell( + onTap: () => _copy(_info.nodeId!, 'Node ID'), + child: Row( + children: [ + Expanded( + child: Text( + _info.nodeId!, + style: const TextStyle(fontSize: 10, fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis, + ), + ), + Icon(Icons.copy, size: 12, color: scheme.onSurfaceVariant), + ], + ), + ), + ], + if (_usage != null) ...[ + const SizedBox(height: 6), + Text( + '${_usage!.resourceCount} resources · ${formatBytes(_usage!.totalBytes)}', + style: labelStyle, + ), + if (_usage!.blobBytes > 0) + Text( + 'files ${formatBytes(_usage!.blobBytes)} · edits ${formatBytes(_usage!.loroBytes)}', + style: labelStyle.copyWith(fontSize: 10), + ), + ], + ]; + } + + /// A paired device (Iroh peer), shown alongside the always-on ones. Live + /// means it holds a connection right now; the green dot mirrors the browser. + /// Below the name we show the node id (copyable, so it can be pasted into + /// another device's "Connect by address") and when we last synced. + Widget _peerCard(ThemeData theme, Map peer) { + final nodeId = peer['node_id'] ?? ''; + final name = (peer['name'] ?? '').trim(); + final label = name.isNotEmpty + ? name + : '${nodeId.substring(0, nodeId.length.clamp(0, 12))}…'; + final isLive = isLiveAtomicPeer(nodeId, _livePeerIds); + final scheme = theme.colorScheme; + final labelStyle = TextStyle(fontSize: 11, color: scheme.onSurfaceVariant); + final lastSynced = int.tryParse(peer['last_synced'] ?? ''); + + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: scheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.phone_android, + size: 18, color: scheme.onSurfaceVariant), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: const TextStyle(fontSize: 14), + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isLive + ? Colors.green.withValues(alpha: 0.15) + : scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + isLive ? 'Connected' : 'Paired', + style: TextStyle( + fontSize: 10, + color: isLive + ? Colors.green.shade800 + : scheme.onSurfaceVariant, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 16), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + tooltip: 'Remove', + onPressed: _busy + ? null + : () => _runAction(() async { + await widget.backend.removeKnownPeer(nodeId); + if (!mounted) return; + setState(() => _peers + .removeWhere((p) => p['node_id'] == nodeId)); + }), + ), + ], + ), + if (nodeId.isNotEmpty) ...[ + const SizedBox(height: 6), + InkWell( + onTap: () => _copy(nodeId, 'Device ID'), + child: Row( + children: [ + Expanded( + child: Text( + nodeId, + style: const TextStyle( + fontSize: 10, fontFamily: 'monospace'), + overflow: TextOverflow.ellipsis, + ), + ), + Icon(Icons.copy, size: 12, color: scheme.onSurfaceVariant), + ], + ), + ), + ], + const SizedBox(height: 4), + Text( + lastSynced != null + ? 'Last synced ${_relativeTime(lastSynced)}' + : 'Not synced yet', + style: labelStyle, + ), + ], + ), + ); + } + + /// Coarse "2m ago" / "3h ago" / "5d ago" for a unix-millis timestamp — enough + /// to tell "just now" from "days back" without a date library. + String _relativeTime(int millis) { + final delta = + DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(millis)); + + if (delta.inSeconds < 60) return 'just now'; + if (delta.inMinutes < 60) return '${delta.inMinutes}m ago'; + if (delta.inHours < 24) return '${delta.inHours}h ago'; + + return '${delta.inDays}d ago'; + } + + /// Show a QR code for another device to scan, then reload so the freshly + /// paired device appears in the list above. + Future _pairWithQr() async { + try { + await widget.backend.pair(context); + if (mounted) await _load(); + } catch (e) { + if (mounted) setState(() => _error = '$e'); + } + } + + Widget _addButton() { + return Wrap( + spacing: 8, + children: [ + TextButton.icon( + onPressed: _pairWithQr, + icon: const Icon(Icons.qr_code_2, size: 16), + label: + const Text('Pair with QR code', style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom(padding: EdgeInsets.zero), + ), + if (widget.backend.servers != null) + TextButton.icon( + onPressed: () => setState(() => _showAdd = true), + icon: const Icon(Icons.add, size: 16), + label: const Text('Connect by address', + style: TextStyle(fontSize: 12)), + style: TextButton.styleFrom(padding: EdgeInsets.zero), + ), + ], + ); + } + + Widget _addField(ThemeData theme) { + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _addController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'localhost:9883', + border: OutlineInputBorder(), + isDense: true, + ), + keyboardType: TextInputType.url, + autocorrect: false, + onSubmitted: (_) => _runAction(_add), + ), + ), + const SizedBox(width: 8), + TextButton( + onPressed: _busy ? null : () => _runAction(_add), + child: const Text('Add'), + ), + TextButton( + onPressed: () => setState(() { + _showAdd = false; + _addController.clear(); + }), + child: const Text('Cancel'), + ), + ], + ), + ); + } +} diff --git a/packages/atomic_flutter/lib/src/server_url.dart b/packages/atomic_flutter/lib/src/server_url.dart new file mode 100644 index 0000000000..221ae4c25b --- /dev/null +++ b/packages/atomic_flutter/lib/src/server_url.dart @@ -0,0 +1,86 @@ +/// Server URL handling, shared by every screen that takes one from a user. +/// +/// Kept in step with `normalizeServerUrl` / `sameOrigin` in the data-browser's +/// SyncRoute: the same URL typed into either client should mean the same thing. +library; + +/// A server URL as typed by a person, made into one that can be fetched. +/// +/// Requiring a scheme is a papercut — people type `localhost:9883`. Local +/// addresses get `http` (there is no certificate on a dev box), everything +/// else `https`. An explicit scheme is always kept. +String normalizeServerUrl(String input) { + final trimmed = input.trim().replaceAll(RegExp(r'/+$'), ''); + + if (RegExp(r'^https?://', caseSensitive: false).hasMatch(trimmed)) { + return trimmed; + } + + if (trimmed.isEmpty) { + return ''; + } + + return '${isLocalAddress(trimmed) ? 'http' : 'https'}://$trimmed'; +} + +/// Whether `authority` (`host` or `host:port`) names a machine on this network +/// rather than the internet. +/// +/// A phone cannot reach `localhost` — the dev server it wants is at the LAN +/// address of the machine running it, so treating only `localhost` as local +/// would default the one address a phone actually needs to `https`, which no +/// dev server speaks. Private ranges can't hold public certificates anyway. +bool isLocalAddress(String authority) { + final host = authority.split(':').first.toLowerCase(); + + if (host == 'localhost' || host == '::1' || host.endsWith('.local')) { + return true; + } + + final octets = host.split('.'); + + if (octets.length != 4 || octets.any((o) => int.tryParse(o) == null)) { + return false; + } + + final [a, b, _, _] = octets.map(int.parse).toList(); + + // The private ranges (RFC 1918) plus loopback. + return a == 127 || + a == 10 || + (a == 192 && b == 168) || + (a == 172 && b >= 16 && b <= 31); +} + +/// Whether two server URLs point at the same server — how "is this the one in +/// use?" is decided, tolerant of trailing slashes and paths. +bool sameOrigin(String a, String? b) { + if (b == null || b.isEmpty) { + return false; + } + + try { + final ua = Uri.parse(a); + final ub = Uri.parse(b); + + return ua.scheme == ub.scheme && ua.host == ub.host && ua.port == ub.port; + } catch (_) { + return false; + } +} + +/// A server URL as shown to a person: the bare authority, keeping the port +/// (which distinguishes two dev servers) and dropping only the scheme. +String serverLabel(String url) { + try { + final parsed = Uri.parse(url); + + if (parsed.host.isEmpty) { + return url; + } + + return parsed.hasPort ? '${parsed.host}:${parsed.port}' : parsed.host; + } catch (_) { + return url; + } +} diff --git a/packages/atomic_flutter/lib/src/settings_backend.dart b/packages/atomic_flutter/lib/src/settings_backend.dart new file mode 100644 index 0000000000..07e3f923f8 --- /dev/null +++ b/packages/atomic_flutter/lib/src/settings_backend.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'server_info.dart'; + +/// Public identity only. Secrets are retrieved on explicit user action. +class AtomicIdentity { + const AtomicIdentity({required this.subject, this.name}); + final String subject; + final String? name; +} + +/// Host-owned persistence, authentication and transport for the Canvas UI. +/// Implementations must throw on failure; widgets only update after success. +/// No database, native bridge, global singleton or audio thread is owned here. +abstract class AtomicSettingsBackend { + Future getActiveAgent(); + Future exportSecret(); + Future> listDrives(); + String? getActiveDrive(); + Future getDriveName(String drive); + Future createDrive(String name); + + /// Persist the selection and switch any application-specific data atomically. + Future switchDrive(String drive); + Future getPeerId(); + Future getDeviceName(); + Future setDeviceName(String name); + Future>> getKnownPeers(); + Future> livePeerIds(); + Future removeKnownPeer(String nodeId); + Future pair(BuildContext context); + + /// Optional capabilities; unavailable actions are not displayed. + AtomicServerBackend? get servers => null; + Future Function(BuildContext context)? get signInWithAccount => null; + Future Function()? get syncAccount => null; + Future Function()? get useLocalProjects => null; + String? get accountStatus => null; + Future Function()? get signOut => null; + Future Function(BuildContext context)? get signIn => null; +} + +/// Optional always-on server support. The host keeps credentials and signed +/// HTTP reads; the shared widgets receive only public server/usage data. +abstract class AtomicServerBackend { + Future> knownServers(); + Future activeServer(); + Future serverInfo(String url); + Future driveUsage(String url); + Future switchTo(String url); + Future add(String url); + Future remove(String url); + Future pushWorkspace(String url); +} + +bool isLiveAtomicPeer(String known, Set live) { + String normalize(String id) { + var value = id.trim().toLowerCase(); + if (value.startsWith('atomic:node:')) { + value = 'did:ad:node:${value.substring(12)}'; + } + if (value.startsWith('did:ad:node:')) { + value = value.substring(12); + if (value.length > 64) value = value.substring(0, 64); + } + if (value.startsWith('iroh:')) value = value.substring(5); + return value; + } + + return live.any((id) => normalize(id) == normalize(known)); +} diff --git a/packages/atomic_flutter/pubspec.yaml b/packages/atomic_flutter/pubspec.yaml new file mode 100644 index 0000000000..12edd44922 --- /dev/null +++ b/packages/atomic_flutter/pubspec.yaml @@ -0,0 +1,19 @@ +name: atomic_flutter +description: Shared Atomic account, device sync, and drive settings UI from Atomic Canvas. +version: 0.1.0 +publish_to: none + +environment: + sdk: '>=3.4.0 <4.0.0' + flutter: '>=3.44.0' +dependencies: + http: ^1.6.0 + cryptography: ^2.9.0 + flutter: + sdk: flutter +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 +flutter: + uses-material-design: true diff --git a/packages/atomic_flutter/test/account_test.dart b/packages/atomic_flutter/test/account_test.dart new file mode 100644 index 0000000000..dd21f69cd8 --- /dev/null +++ b/packages/atomic_flutter/test/account_test.dart @@ -0,0 +1,161 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:atomic_flutter/atomic_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + final fixture = + jsonDecode(File('test/fixtures/account-envelope.json').readAsStringSync()) + as Map; + final envelope = fixture['envelope'] as Map; + test('decrypts browser-compatible AES-GCM and rejects tampering', () async { + final wrapper = + (envelope['wrappers'] as List).single as Map; + expect( + await AtomicAccountClient.openAssistedEnvelope( + envelope, wrapper, fixture['key']), + 'synthetic-atomic-secret'); + final corrupt = base64Decode(envelope['encrypted_secret'] as String) + ..[0] ^= 1; + await expectLater( + AtomicAccountClient.openAssistedEnvelope( + {...envelope, 'encrypted_secret': base64Encode(corrupt)}, + wrapper, + fixture['key']), + throwsStateError); + }); + test('link, recover and discover without exposing tokens to other origins', + () async { + final calls = []; + final client = AtomicAccountClient('https://provider.example', + client: MockClient((request) async { + expect(request.url.origin, 'https://provider.example'); + expect(request.followRedirects, isFalse); + calls.add(request.url.path); + if (request.url.path.startsWith('/api/device-link')) { + expect(request.headers.containsKey('Authorization'), isFalse); + } else { + expect(request.headers['Authorization'], 'Bearer private-test-token'); + } + switch (request.url.path) { + case '/api/device-link': + return http.Response( + jsonEncode({ + 'device_code': 'device-secret', + 'user_code': 'ABCD-EFGH', + 'expires_in': 300, + 'interval': 2 + }), + 200); + case '/api/device-link/device-secret': + return http.Response( + '{"state":"approved","token":"private-test-token"}', 200); + case '/api/me': + return http.Response( + '{"email":"acct_test","address":"person@example.invalid"}', 200); + case '/api/recovery-secret': + return http.Response(jsonEncode(envelope), 200); + case '/api/recovery-secret/assisted-key': + return http.Response(jsonEncode({'key': fixture['key']}), 200); + case '/api/devices': + return http.Response('{"devices":[]}', 200); + case '/api/sync-enrollments': + return http.Response('[]', 200); + } + throw StateError('Unexpected request'); + })); + final link = await client.requestLink('Audio Mac'); + expect(link.approvalUrl.toString(), + 'https://provider.example/link?code=ABCD-EFGH'); + expect(link.approvalUrl.toString(), isNot(contains('device-secret'))); + expect(await client.pollLink(link), AtomicLinkProgress.approved); + expect((await client.recoverIdentity()).secret, 'synthetic-atomic-secret'); + expect(await client.devices(), isEmpty); + expect(await client.enrollments(), isEmpty); + expect( + () => AtomicAccountClient('https://other.example') + .restoreSession(client.exportSession()), + throwsStateError); + expect(calls, hasLength(7)); + client.close(); + }); + test( + 'rejects unsafe origins, redirects, missing backups and account mismatch', + () async { + for (final url in [ + 'http://provider.example', + 'https://user:pass@provider.example', + 'https://provider.example/path', + 'https://provider.example?query=secret' + ]) { + expect(() => AtomicAccountClient(url), throwsArgumentError); + } + final client = AtomicAccountClient('https://provider.example', + client: MockClient((r) async => http.Response('', 302, + headers: {'location': 'https://other.example'}))); + await expectLater(client.requestLink('test'), throwsStateError); + final mismatch = AtomicAccountClient('https://provider.example', + client: MockClient((r) async => http.Response( + jsonEncode(r.url.path == '/api/me' + ? {'email': 'another-account'} + : envelope), + 200))) + ..restoreSession('{"origin":"https://provider.example","token":"test"}'); + await expectLater(mismatch.recoverIdentity(), throwsStateError); + client.close(); + mismatch.close(); + }); + test('expired links and malformed replies never produce a linked session', + () async { + final client = AtomicAccountClient('https://provider.example', + client: MockClient((r) async => http.Response('', 404))); + final link = AtomicDeviceLink.fromJson({ + 'device_code': 'private', + 'user_code': 'PUBLIC', + 'expires_in': 300, + 'interval': 120 + }, Uri.parse('https://provider.example')); + expect(link.interval, const Duration(seconds: 120)); + expect(await client.pollLink(link), AtomicLinkProgress.expired); + expect(client.linked, isFalse); + final broken = AtomicAccountClient('https://provider.example', + client: MockClient( + (r) async => http.Response('secret response malformed', 200))); + try { + await broken.requestLink('test'); + fail('must reject malformed response'); + } catch (error) { + expect(error.toString(), isNot(contains('secret response'))); + } + client.close(); + broken.close(); + }); + + testWidgets('closing approval dialog never installs a late response', + (tester) async { + final response = Completer(); + var installed = false; + final client = AtomicAccountClient('https://provider.example', + client: MockClient((r) => response.future)); + await tester.pumpWidget(MaterialApp( + home: AtomicAccountLinkDialog( + client: client, + deviceName: 'test', + openUrl: (_) async {}, + install: (identity, session) async { + installed = true; + }))); + await tester.pumpWidget(const SizedBox()); + response.complete(http.Response( + '{"device_code":"private","user_code":"PUBLIC","expires_in":300,"interval":1}', + 200)); + await tester.pump(const Duration(seconds: 3)); + expect(installed, isFalse); + expect(tester.takeException(), isNull); + client.close(); + }); +} diff --git a/packages/atomic_flutter/test/fixtures/account-envelope.json b/packages/atomic_flutter/test/fixtures/account-envelope.json new file mode 100644 index 0000000000..65d9f799ef --- /dev/null +++ b/packages/atomic_flutter/test/fixtures/account-envelope.json @@ -0,0 +1 @@ +{"key":"BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=","envelope":{"owner_email":"acct_test","agent_subject":"atomic:agent:test","drive_subject":"atomic:drive:test","format_version":2,"encryption_algorithm":"AES-GCM","nonce":"AwMDAwMDAwMDAwMD","encrypted_secret":"ou2FBj3ZH8W4j84Bg1W7kZSMpDL2meGksXzbDzRV0rgDZfJiP52G","wrappers":[{"wrapper_type":"atomic-assisted","salt":"test-salt","wrapped_dek":"XDohwW/gcgEBq6THBHjadAu+w0osm7IARp97TMJ1dEvsLDBPJteXJBAAo5M+4Jhb","wrap_nonce":"BAQEBAQEBAQEBAQE"}]}} \ No newline at end of file diff --git a/packages/atomic_flutter/test/settings_test.dart b/packages/atomic_flutter/test/settings_test.dart new file mode 100644 index 0000000000..3553afb4b1 --- /dev/null +++ b/packages/atomic_flutter/test/settings_test.dart @@ -0,0 +1,155 @@ +import 'dart:async'; +import 'package:atomic_flutter/atomic_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class TestBackend extends AtomicSettingsBackend { + String active = 'drive-a'; + bool failSwitch = false; + bool failLoad = false; + int exports = 0; + int paired = 0; + final names = {'drive-a': 'Music', 'drive-b': 'Drawings'}; + Completer>? pending; + @override + Future getActiveAgent() async => + const AtomicIdentity(subject: 'did:ad:test', name: 'Musician'); + @override + Future exportSecret() async { + exports++; + return 'test-secret'; + } + + @override + Future> listDrives() async { + if (failLoad) throw StateError('Drive store unavailable'); + return pending == null ? names.keys.toList() : pending!.future; + } + + @override + String? getActiveDrive() => active; + @override + Future getDriveName(String drive) async => names[drive]!; + @override + Future createDrive(String name) async { + names['drive-${names.length}'] = name; + } + + @override + Future switchDrive(String drive) async { + if (failSwitch) throw StateError('Drive switch failed'); + active = drive; + } + + @override + Future getPeerId() async => 'short-id'; + @override + Future getDeviceName() async => + 'A very long tablet device name that must fit on a narrow screen'; + @override + Future setDeviceName(String name) async {} + @override + Future>> getKnownPeers() async => []; + @override + Future> livePeerIds() async => {}; + @override + Future removeKnownPeer(String nodeId) async {} + @override + Future pair(BuildContext context) async { + paired++; + } +} + +Future showSettings(WidgetTester tester, TestBackend backend) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => + AgentSettingsDialog.show(context, backend: backend), + child: const Text('User'), + ))))); + await tester.tap(find.text('User')); + await tester.pumpAndSettle(); +} + +void main() { + test('canonical peer identifiers match the same legacy node', () { + final id = 'a' * 64; + expect(isLiveAtomicPeer('atomic:node:$id', {'did:ad:node:$id'}), isTrue); + }); + + testWidgets('Canvas dialog works at phone width and switches/creates drives', + (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final backend = TestBackend(); + await showSettings(tester, backend); + expect(find.text('Identity'), findsOneWidget); + expect(find.text('Drives'), findsOneWidget); + expect(find.text('Connect by address'), findsNothing); + expect(find.text('Sign out'), findsNothing); + expect(backend.exports, 0); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Pair with QR code')); + await tester.pumpAndSettle(); + expect(backend.paired, 1); + await tester.ensureVisible(find.text('Drawings')); + await tester.tap(find.text('Drawings')); + await tester.pumpAndSettle(); + expect(backend.active, 'drive-b'); + await tester.ensureVisible(find.text('New drive')); + await tester.tap(find.text('New drive')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'New music'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(backend.names.values, contains('New music')); + expect(find.text('New music'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'failed drive selection preserves the active drive and reports error', + (tester) async { + final backend = TestBackend()..failSwitch = true; + await showSettings(tester, backend); + await tester.ensureVisible(find.text('Drawings')); + await tester.tap(find.text('Drawings')); + await tester.pumpAndSettle(); + expect(backend.active, 'drive-a'); + expect(find.textContaining('Failed to switch drive'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('load error offers retry and recovers', (tester) async { + final backend = TestBackend()..failLoad = true; + await showSettings(tester, backend); + expect(find.textContaining('Drive store unavailable'), findsOneWidget); + backend.failLoad = false; + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + expect(find.text('Musician'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('closing during load does not update a disposed dialog', + (tester) async { + final backend = TestBackend()..pending = Completer>(); + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: AgentSettingsDialog(backend: backend)))); + await tester.pump(); + await tester.pumpWidget(const SizedBox()); + backend.pending!.complete(['drive-a']); + await tester.pump(); + expect(tester.takeException(), isNull); + }); + + test('peer identifiers match between Atomic DID and Iroh forms', () { + final id = List.filled(64, 'a').join(); + expect(isLiveAtomicPeer('did:ad:node:$id', {'iroh:$id'}), isTrue); + expect(isLiveAtomicPeer('did:ad:node:$id:relay', {id}), isTrue); + }); +} diff --git a/planning/atomic-flutter-package.md b/planning/atomic-flutter-package.md new file mode 100644 index 0000000000..45f5cc177a --- /dev/null +++ b/planning/atomic-flutter-package.md @@ -0,0 +1,16 @@ +# Shared Atomic Flutter package + +- [x] Extract the actual Canvas settings and Devices widgets into `atomic_flutter`. +- [x] Define an app-independent backend contract; keep secrets and transports in the host. +- [x] Switch Canvas to the package through its existing public entry points. +- [x] Test phone layouts, drive operations, failures and disposal during load. +- [x] Keep optional server/account capabilities explicit. +- [x] Include drives recorded on the private home in the native drive list; preserve legacy entries. +- [ ] Extract the pairing screen and parser, with injectable QR scanner support. +- [ ] Extract secure account session storage and sign-in flow after agreeing the native/web API. +- [ ] Add a standalone example host and package publishing metadata. +- [ ] Verify Canvas and Audio pairing/account flows on physical devices. + +This draft is the shared UI boundary, not yet a general Dart SDK for the Atomic +Rust API. The existing host adapters continue to own Iroh, Loro, file sync and +identity persistence. diff --git a/planning/flutter-account-link.md b/planning/flutter-account-link.md new file mode 100644 index 0000000000..7d6142c10f --- /dev/null +++ b/planning/flutter-account-link.md @@ -0,0 +1,14 @@ +# Flutter account linking + +Status: implemented draft, production acceptance pending. + +- [x] Provider-neutral device-link client in atomic_flutter; explicit HTTPS origin and no HTTP redirects. +- [x] Shared browser approval dialog with cancellable polling and host-owned credential installation. +- [x] AES-GCM assisted identity recovery compatible with browser envelope-v2; never overwrites a backup. +- [x] Discover account devices and Cloud Server enrollments. +- [x] Native live File updates request missing BLAKE3 bytes after admission and persistence. +- [x] Atomic Audio consumes the package with separate account directories and canonical identifier migration. +- [x] Mock protocol, independent AES fixture, cancellation, profile isolation and real native peer sync checks. +- [ ] Live atomic.place account creation/approval and cross-app data check. Browser permission currently blocks that origin. +- [ ] Cloud Vault transport in Flutter. This first connection supports enrolled Cloud Servers and reachable peer devices; it does not implement encrypted Vault object storage. +- [ ] Hot identity switching. Audio currently stages the recovered profile and requires restart so existing native peers never change identity underneath a running task. diff --git a/planning/sync-onboarding-ux.md b/planning/sync-onboarding-ux.md index 6b25bbf1fb..9f118b7c85 100644 --- a/planning/sync-onboarding-ux.md +++ b/planning/sync-onboarding-ux.md @@ -101,8 +101,8 @@ Keep these in step. A change to one is usually a change to its twin. | Concern | Browser | Flutter | | --- | --- | --- | -| sync screen | `data-browser/src/routes/SyncRoute.tsx` | `flutter/lib/atomic/widgets/server_settings_section.dart` | -| settings shell | (same route) | `flutter/lib/atomic/widgets/agent_settings_dialog.dart` | +| sync screen | `data-browser/src/routes/SyncRoute.tsx` | `packages/atomic_flutter/lib/src/server_settings_section.dart` | +| settings shell | (same route) | `packages/atomic_flutter/lib/src/agent_settings_dialog.dart` | | onboarding, data elsewhere | `data-browser/src/views/getting-started/ConnectDeviceStep.tsx` | `flutter/lib/screens/login_screen.dart` | | pairing code, show / scan | `components/PairingCode.tsx`, `ConnectToDeviceForm.tsx` | `flutter/lib/screens/pair_screen.dart` | | pairing code, format | `browser/lib/src/pairing.ts` | `pair_screen.dart` (`_parsePairingUri`) | @@ -228,3 +228,11 @@ homepage panel, the app's sign-in and restore steps) renders the same adds only what the portal cannot do, pasting an agent secret. Native builds that cannot hold the account cookie link the device with a code instead. Flutter has no account sign-in yet. + +## Shared Flutter settings package + +`packages/atomic_flutter` now owns the Canvas settings and Devices widgets. +Canvas delegates storage and transport through `flutter/lib/atomic/settings_backend.dart`. +Atomic Audio can provide its own adapter without copying the dialog. The browser +SyncRoute remains the visual/wording twin; this extraction changes no browser +behavior. Pairing and authentication screens remain host-owned for now.