diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 5afee09..8c0ffd7 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -16,5 +16,6 @@ Thanks to the people who have contributed fixes, testing, and improvements to Co - [dorokuma](https://github.com/dorokuma) - Fixed Android on-screen keyboard behavior for TUI apps in `conduit_vt`. - Fixed terminal layout distortion on app resume. + - Added optional terminal mouse tap forwarding. - Added README.zh.md, translation of README.md. - Updated Readme.zh.md to include acknowledgments etc. diff --git a/fastlane/metadata/android/en-US/changelogs/391.txt b/fastlane/metadata/android/en-US/changelogs/391.txt new file mode 100644 index 0000000..c7c1d82 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/391.txt @@ -0,0 +1,3 @@ +Terminal: optional mouse tap forwarding can now be enabled from Appearance for terminal apps that use mouse tracking. +Terminal: choose whether Enter sends CR, LF, or CRLF from Appearance. +Terminal: fixed fish prompt underline artifacts and clear-screen spacing. diff --git a/fastlane/metadata/android/en-US/changelogs/392.txt b/fastlane/metadata/android/en-US/changelogs/392.txt new file mode 100644 index 0000000..c7c1d82 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/392.txt @@ -0,0 +1,3 @@ +Terminal: optional mouse tap forwarding can now be enabled from Appearance for terminal apps that use mouse tracking. +Terminal: choose whether Enter sends CR, LF, or CRLF from Appearance. +Terminal: fixed fish prompt underline artifacts and clear-screen spacing. diff --git a/fastlane/metadata/android/en-US/changelogs/393.txt b/fastlane/metadata/android/en-US/changelogs/393.txt new file mode 100644 index 0000000..c7c1d82 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/393.txt @@ -0,0 +1,3 @@ +Terminal: optional mouse tap forwarding can now be enabled from Appearance for terminal apps that use mouse tracking. +Terminal: choose whether Enter sends CR, LF, or CRLF from Appearance. +Terminal: fixed fish prompt underline artifacts and clear-screen spacing. diff --git a/lib/core/presentation/theme_sheet.dart b/lib/core/presentation/theme_sheet.dart index bfdf828..fa66ad0 100644 --- a/lib/core/presentation/theme_sheet.dart +++ b/lib/core/presentation/theme_sheet.dart @@ -332,6 +332,75 @@ class _TerminalAppearanceControls extends StatelessWidget { ), ), const SizedBox(height: 14), + Material( + color: colorScheme.surface, + shape: RoundedRectangleBorder( + side: BorderSide(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(14), + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.keyboard_return_rounded, size: 20), + const SizedBox(width: 10), + Text('Enter sends', style: theme.textTheme.titleSmall), + ], + ), + const SizedBox(height: 6), + Text( + controller.terminalEnterSequence.description, + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: SegmentedButton( + segments: [ + for (final sequence in TerminalEnterSequence.values) + ButtonSegment( + value: sequence, + label: Text(sequence.label), + ), + ], + selected: {controller.terminalEnterSequence}, + onSelectionChanged: (selection) { + controller.setTerminalEnterSequence(selection.single); + }, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 14), + Material( + color: colorScheme.surface, + shape: RoundedRectangleBorder( + side: BorderSide(color: colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(14), + ), + clipBehavior: Clip.antiAlias, + child: SwitchListTile( + secondary: const Icon(Icons.mouse_rounded), + title: const Text('Send mouse taps'), + subtitle: Text( + 'Forward terminal taps as mouse clicks when apps enable mouse tracking.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + value: controller.terminalMouseInput, + onChanged: controller.setTerminalMouseInput, + ), + ), + const SizedBox(height: 14), Container( padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), decoration: BoxDecoration( diff --git a/lib/core/theme/terminal_appearance.dart b/lib/core/theme/terminal_appearance.dart index 9507bb9..3b1078e 100644 --- a/lib/core/theme/terminal_appearance.dart +++ b/lib/core/theme/terminal_appearance.dart @@ -1,5 +1,7 @@ enum TerminalFontOption { systemMonospace, atkynsonNerdFont } +enum TerminalEnterSequence { cr, lf, crlf } + const terminalFontSizeDefault = 13.5; const terminalFontSizeMin = 4.0; const terminalFontSizeMax = 30.0; @@ -28,6 +30,26 @@ extension TerminalFontOptionDetails on TerminalFontOption { }; } +extension TerminalEnterSequenceDetails on TerminalEnterSequence { + String get label => switch (this) { + TerminalEnterSequence.cr => 'CR', + TerminalEnterSequence.lf => 'LF', + TerminalEnterSequence.crlf => 'CRLF', + }; + + String get description => switch (this) { + TerminalEnterSequence.cr => 'Carriage return', + TerminalEnterSequence.lf => 'Line feed', + TerminalEnterSequence.crlf => 'Carriage return + line feed', + }; + + String get value => switch (this) { + TerminalEnterSequence.cr => '\r', + TerminalEnterSequence.lf => '\n', + TerminalEnterSequence.crlf => '\r\n', + }; +} + enum TerminalKeyboardAction { escape, control, diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index 1e72bad..e812bce 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -16,6 +16,8 @@ class ThemeController extends ChangeNotifier { List _terminalKeyboardRows = defaultTerminalKeyboardRows; List _terminalSnippets = const []; bool _showLocalShell = true; + bool _terminalMouseInput = false; + TerminalEnterSequence _terminalEnterSequence = TerminalEnterSequence.cr; ThemeMode get themeMode => _themeMode; AppPalette get palette => _palette; @@ -26,6 +28,8 @@ class ThemeController extends ChangeNotifier { List get terminalSnippets => List.unmodifiable(_terminalSnippets); bool get showLocalShell => _showLocalShell; + bool get terminalMouseInput => _terminalMouseInput; + TerminalEnterSequence get terminalEnterSequence => _terminalEnterSequence; Future load() async { final preferences = await _repository.load(); @@ -36,6 +40,8 @@ class ThemeController extends ChangeNotifier { _terminalKeyboardRows = List.of(preferences.terminalKeyboardRows); _terminalSnippets = List.of(preferences.terminalSnippets); _showLocalShell = preferences.showLocalShell; + _terminalMouseInput = preferences.terminalMouseInput; + _terminalEnterSequence = preferences.terminalEnterSequence; notifyListeners(); } @@ -140,6 +146,24 @@ class ThemeController extends ChangeNotifier { await _save(); } + Future setTerminalMouseInput(bool enabled) async { + if (_terminalMouseInput == enabled) { + return; + } + _terminalMouseInput = enabled; + notifyListeners(); + await _save(); + } + + Future setTerminalEnterSequence(TerminalEnterSequence sequence) async { + if (_terminalEnterSequence == sequence) { + return; + } + _terminalEnterSequence = sequence; + notifyListeners(); + await _save(); + } + Future _save() { return _repository.save( ThemePreferences( @@ -150,6 +174,8 @@ class ThemeController extends ChangeNotifier { terminalKeyboardRows: _terminalKeyboardRows, terminalSnippets: _terminalSnippets, showLocalShell: _showLocalShell, + terminalMouseInput: _terminalMouseInput, + terminalEnterSequence: _terminalEnterSequence, ), ); } diff --git a/lib/core/theme/theme_preferences_repository.dart b/lib/core/theme/theme_preferences_repository.dart index 12663a2..df75910 100644 --- a/lib/core/theme/theme_preferences_repository.dart +++ b/lib/core/theme/theme_preferences_repository.dart @@ -15,6 +15,8 @@ class ThemePreferences { this.terminalKeyboardRows = defaultTerminalKeyboardRows, this.terminalSnippets = const [], this.showLocalShell = true, + this.terminalMouseInput = false, + this.terminalEnterSequence = TerminalEnterSequence.cr, }); final ThemeMode themeMode; @@ -24,6 +26,8 @@ class ThemePreferences { final List terminalKeyboardRows; final List terminalSnippets; final bool showLocalShell; + final bool terminalMouseInput; + final TerminalEnterSequence terminalEnterSequence; } class ThemePreferencesRepository { @@ -40,6 +44,8 @@ class ThemePreferencesRepository { 'conduit.terminal_keyboard_seen_actions.v1'; static const _terminalSnippetsKey = 'conduit.terminal_snippets.v1'; static const _showLocalShellKey = 'conduit.show_local_shell.v1'; + static const _terminalMouseInputKey = 'conduit.terminal_mouse_input.v1'; + static const _terminalEnterSequenceKey = 'conduit.terminal_enter_sequence.v1'; final FlutterSecureStorage _storage; @@ -59,6 +65,12 @@ class ThemePreferencesRepository { ); final rawTerminalSnippets = await _storage.read(key: _terminalSnippetsKey); final rawShowLocalShell = await _storage.read(key: _showLocalShellKey); + final rawTerminalMouseInput = await _storage.read( + key: _terminalMouseInputKey, + ); + final rawTerminalEnterSequence = await _storage.read( + key: _terminalEnterSequenceKey, + ); final terminalFontSize = double.tryParse(rawTerminalFontSize ?? ''); final terminalKeyboardRows = _appendUnseenBuiltIns( _parseTerminalKeyboardRows( @@ -87,6 +99,11 @@ class ThemePreferencesRepository { terminalKeyboardRows: terminalKeyboardRows, terminalSnippets: _parseTerminalSnippets(rawTerminalSnippets), showLocalShell: rawShowLocalShell == null || rawShowLocalShell == 'true', + terminalMouseInput: rawTerminalMouseInput == 'true', + terminalEnterSequence: TerminalEnterSequence.values.firstWhere( + (sequence) => sequence.name == rawTerminalEnterSequence, + orElse: () => TerminalEnterSequence.cr, + ), ); } @@ -129,6 +146,14 @@ class ThemePreferencesRepository { key: _showLocalShellKey, value: preferences.showLocalShell.toString(), ); + await _storage.write( + key: _terminalMouseInputKey, + value: preferences.terminalMouseInput.toString(), + ); + await _storage.write( + key: _terminalEnterSequenceKey, + value: preferences.terminalEnterSequence.name, + ); } List _parseTerminalSnippets(String? raw) { diff --git a/lib/features/backup/data/app_backup_service.dart b/lib/features/backup/data/app_backup_service.dart index e9af357..566e676 100644 --- a/lib/features/backup/data/app_backup_service.dart +++ b/lib/features/backup/data/app_backup_service.dart @@ -142,6 +142,8 @@ class AppBackupService { (includeSecrets ? snippet : _snippetForBackup(snippet)).toJson(), ], 'showLocalShell': _themeController.showLocalShell, + 'terminalMouseInput': _themeController.terminalMouseInput, + 'terminalEnterSequence': _themeController.terminalEnterSequence.name, }; } @@ -189,6 +191,16 @@ class AppBackupService { if (showLocalShell is bool) { await _themeController.setShowLocalShell(showLocalShell); } + final terminalMouseInput = json['terminalMouseInput']; + if (terminalMouseInput is bool) { + await _themeController.setTerminalMouseInput(terminalMouseInput); + } + await _themeController.setTerminalEnterSequence( + TerminalEnterSequence.values.firstWhere( + (sequence) => sequence.name == json['terminalEnterSequence'], + orElse: () => _themeController.terminalEnterSequence, + ), + ); } Map _decodeDocument(Uint8List bytes) { diff --git a/lib/features/hosts/domain/saved_host.dart b/lib/features/hosts/domain/saved_host.dart index 35947a2..55af2cb 100644 --- a/lib/features/hosts/domain/saved_host.dart +++ b/lib/features/hosts/domain/saved_host.dart @@ -152,10 +152,10 @@ class SavedHost { this.isLocal = false, }); - factory SavedHost.localShell({required String id}) { + factory SavedHost.localShell({required String id, required String name}) { return SavedHost( id: id, - name: 'Arch Linux', + name: name, host: 'localhost', port: 0, username: 'root', diff --git a/lib/features/hosts/presentation/hosts_page.dart b/lib/features/hosts/presentation/hosts_page.dart index 2c0429a..b33c1f5 100644 --- a/lib/features/hosts/presentation/hosts_page.dart +++ b/lib/features/hosts/presentation/hosts_page.dart @@ -16,9 +16,11 @@ import 'package:conduit/features/hosts/presentation/widgets/host_search_field.da import 'package:conduit/features/hosts/presentation/widgets/hosts_hero.dart'; import 'package:conduit/features/hosts/presentation/widgets/message_state.dart'; import 'package:conduit/features/hosts/presentation/widgets/tag_filter_bar.dart'; +import 'package:conduit/features/local_shell/domain/local_shell_instance.dart'; import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; -import 'package:conduit/features/local_shell/presentation/local_shell_page.dart'; -import 'package:conduit/features/local_shell/presentation/widgets/local_shell_card.dart'; +import 'package:conduit/features/local_shell/presentation/local_shell_instance_page.dart'; +import 'package:conduit/features/local_shell/presentation/local_shell_setup_page.dart'; +import 'package:conduit/features/local_shell/presentation/widgets/local_shell_section.dart'; import 'package:conduit/features/sftp/domain/file_export.dart'; import 'package:conduit/features/sftp/domain/sftp_repository.dart'; import 'package:conduit/features/sftp/presentation/sftp_browser_page.dart'; @@ -178,14 +180,21 @@ class _HostsPageState extends State { if (!widget.themeController.showLocalShell) { return const SizedBox.shrink(); } - final active = widget.workspaceController.sessions.any( - (session) => session.host.id == localShellHostId, - ); - return LocalShellCard( + final activeInstanceIds = widget + .workspaceController + .sessions + .map( + (session) => + localShellInstanceIdFromHostId(session.host.id), + ) + .whereType() + .toSet(); + return LocalShellSection( controller: widget.localShellController, - active: active, - onOpenSession: _openLocalSession, - onManage: _openLocalShell, + activeInstanceIds: activeInstanceIds, + onAdd: _openLocalShellSetup, + onOpenInstance: _openLocalSession, + onManageInstance: _openLocalShellInstance, ); }, ), @@ -437,19 +446,40 @@ class _HostsPageState extends State { _terminalPageOpen = false; } - Future _openLocalShell() async { - await Navigator.of(context).push( + Future _openLocalShellSetup() async { + final request = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + LocalShellSetupPage(controller: widget.localShellController), + ), + ); + if (request == null) return; + unawaited( + widget.localShellController.installNew( + request.distroId, + name: request.name, + ), + ); + } + + void _openLocalShellInstance(LocalShellInstance instance) { + Navigator.of(context).push( MaterialPageRoute( - builder: (_) => LocalShellPage( + builder: (_) => LocalShellInstancePage( controller: widget.localShellController, - onOpenSession: _openLocalSession, - onCloseSession: _closeLocalSession, + instanceId: instance.id, + onOpenSession: (instance) => + _openLocalSession(instance, forceNew: true), + onCloseSessions: _closeLocalSession, ), ), ); } - Future _openLocalSession() async { + Future _openLocalSession( + LocalShellInstance instance, { + bool forceNew = false, + }) async { if (widget.localShellController.sharedStorageFeatureEnabled && !widget.localShellController.sharedStorageAccessGranted) { await widget.localShellController.requestSharedStorageAccess(); @@ -463,14 +493,31 @@ class _HostsPageState extends State { return; } } - widget.workspaceController.open(widget.localShellController.localHost()); + final existing = widget.workspaceController.sessions + .where( + (session) => + localShellInstanceIdFromHostId(session.host.id) == instance.id, + ) + .toList(); + if (!forceNew && existing.isNotEmpty) { + widget.workspaceController.activate(existing.first); + } else { + widget.workspaceController.open( + widget.localShellController.localHost( + instance, + sessionNumber: existing.length + 1, + ), + ); + } + unawaited(widget.localShellController.markOpened(instance.id)); if (!mounted) return; await _openTerminalWorkspace(); } - Future _closeLocalSession() async { + Future _closeLocalSession(String instanceId) async { final sessions = widget.workspaceController.sessions.where( - (session) => session.host.id == localShellHostId, + (session) => + localShellInstanceIdFromHostId(session.host.id) == instanceId, ); for (final session in List.of(sessions)) { await widget.workspaceController.close(session); diff --git a/lib/features/local_shell/data/first_boot_runner.dart b/lib/features/local_shell/data/first_boot_runner.dart index a5f2ed6..06ad62b 100644 --- a/lib/features/local_shell/data/first_boot_runner.dart +++ b/lib/features/local_shell/data/first_boot_runner.dart @@ -1,8 +1,8 @@ import 'package:conduit/features/local_shell/data/proot_runner.dart'; import 'package:conduit/features/local_shell/domain/first_boot_script.dart'; +import 'package:conduit/features/local_shell/domain/local_shell_distro.dart'; import 'package:conduit/features/local_shell/domain/local_shell_paths.dart'; import 'package:conduit/features/local_shell/domain/proot_command.dart'; -import 'package:conduit/features/local_shell/domain/rootfs_manifest.dart'; class FirstBootException implements Exception { const FirstBootException(this.message); @@ -14,7 +14,7 @@ class FirstBootException implements Exception { } abstract interface class FirstBootRunner { - Future run(RootfsManifest manifest); + Future run(LocalShellDistro distro); } class ProotFirstBootRunner implements FirstBootRunner { @@ -27,11 +27,12 @@ class ProotFirstBootRunner implements FirstBootRunner { final FirstBootScript scriptGenerator; @override - Future run(RootfsManifest manifest) async { + Future run(LocalShellDistro distro) async { final script = scriptGenerator.generate( FirstBootConfig( - pacmanMirror: manifest.pacmanMirror, - keyringName: manifest.keyringName, + distroName: distro.name, + updateCommand: distro.updateCommand, + setupCommands: distro.setupCommands, doneMarkerPath: paths.firstBootMarker, ), ); diff --git a/lib/features/local_shell/data/local_shell_instance_store.dart b/lib/features/local_shell/data/local_shell_instance_store.dart new file mode 100644 index 0000000..0da1405 --- /dev/null +++ b/lib/features/local_shell/data/local_shell_instance_store.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:conduit/features/local_shell/domain/local_shell_distro.dart'; +import 'package:conduit/features/local_shell/domain/local_shell_instance.dart'; +import 'package:path/path.dart' as p; + +class LocalShellInstanceStore { + const LocalShellInstanceStore({required this.dataDir, required this.catalog}); + + final String dataDir; + final List catalog; + + static const metaFileName = '.conduit-instance'; + + Future writeMeta(LocalShellInstance instance) async { + final file = File(p.join(dataDir, instance.id, metaFileName)); + await file.parent.create(recursive: true); + await file.writeAsString( + jsonEncode({'distroId': instance.distroId, 'name': instance.name}), + ); + } + + Future> discover() async { + final root = Directory(dataDir); + if (!await root.exists()) return const []; + final instances = []; + await for (final entity in root.list(followLinks: false)) { + if (entity is! Directory) continue; + final instance = await _readInstance(entity); + if (instance != null) instances.add(instance); + } + instances.sort((a, b) { + final byDistro = _catalogIndex( + a.distroId, + ).compareTo(_catalogIndex(b.distroId)); + if (byDistro != 0) return byDistro; + final byLength = a.id.length.compareTo(b.id.length); + return byLength != 0 ? byLength : a.id.compareTo(b.id); + }); + return instances; + } + + Future createInstance( + LocalShellDistro distro, { + String? name, + }) async { + var suffix = 1; + var id = distro.id; + while (await Directory(p.join(dataDir, id)).exists()) { + suffix += 1; + id = '${distro.id}-$suffix'; + } + final trimmed = name?.trim() ?? ''; + return LocalShellInstance( + id: id, + distroId: distro.id, + name: trimmed.isNotEmpty + ? trimmed + : suffix == 1 + ? distro.name + : '${distro.name} $suffix', + ); + } + + Future _readInstance(Directory directory) async { + final id = p.basename(directory.path); + final metaFile = File(p.join(directory.path, metaFileName)); + if (await metaFile.exists()) { + try { + final decoded = jsonDecode(await metaFile.readAsString()); + if (decoded is Map) { + final distroId = (decoded['distroId'] as String?)?.trim() ?? ''; + final name = (decoded['name'] as String?)?.trim() ?? ''; + final distro = _distroById(distroId); + if (distro != null) { + return LocalShellInstance( + id: id, + distroId: distroId, + name: name.isEmpty ? distro.name : name, + ); + } + } + } catch (_) {} + return null; + } + final legacyDistro = _distroById(id); + if (legacyDistro != null && + await Directory(p.join(directory.path, 'rootfs')).exists()) { + return LocalShellInstance(id: id, distroId: id, name: legacyDistro.name); + } + return null; + } + + LocalShellDistro? _distroById(String distroId) { + for (final distro in catalog) { + if (distro.id == distroId) return distro; + } + return null; + } + + int _catalogIndex(String distroId) { + for (var i = 0; i < catalog.length; i++) { + if (catalog[i].id == distroId) return i; + } + return catalog.length; + } +} diff --git a/lib/features/local_shell/data/local_terminal_repository.dart b/lib/features/local_shell/data/local_terminal_repository.dart index 8078bf7..4d3a213 100644 --- a/lib/features/local_shell/data/local_terminal_repository.dart +++ b/lib/features/local_shell/data/local_terminal_repository.dart @@ -4,6 +4,7 @@ import 'package:conduit/core/app_failure.dart'; import 'package:conduit/features/hosts/domain/saved_host.dart'; import 'package:conduit/features/local_shell/data/flutter_pty_process.dart'; import 'package:conduit/features/local_shell/data/local_terminal_session.dart'; +import 'package:conduit/features/local_shell/domain/local_shell_distro.dart'; import 'package:conduit/features/local_shell/domain/local_shell_paths.dart'; import 'package:conduit/features/local_shell/domain/proot_command.dart'; import 'package:conduit/features/local_shell/domain/pty_process.dart'; @@ -21,11 +22,11 @@ typedef PtyProcessFactory = class LocalTerminalRepository implements SshTerminalRepository { LocalTerminalRepository({ - required this.resolvePaths, + required this.resolveLaunch, PtyProcessFactory? processFactory, }) : _processFactory = processFactory ?? _defaultProcessFactory; - final Future Function() resolvePaths; + final Future Function(String hostId) resolveLaunch; final PtyProcessFactory _processFactory; static PtyProcess _defaultProcessFactory({ @@ -51,15 +52,21 @@ class LocalTerminalRepository implements SshTerminalRepository { required int rows, }) async { try { - final paths = await resolvePaths(); + final launch = await resolveLaunch(host.id); + final paths = launch.paths; final bindMounts = await _prepareBindMounts(paths); - final command = ProotCommandBuilder( - prootBinary: paths.prootBinary, - loaderPath: paths.loaderPath, - libraryPath: paths.nativeLibraryDir, - tmpDir: paths.tmpDir, - bindMounts: bindMounts, - ).login(rootfsDir: paths.rootfsDir); + final command = + ProotCommandBuilder( + prootBinary: paths.prootBinary, + loaderPath: paths.loaderPath, + libraryPath: paths.nativeLibraryDir, + tmpDir: paths.tmpDir, + bindMounts: bindMounts, + ).login( + rootfsDir: paths.rootfsDir, + command: launch.distro.loginCommand, + promptLabel: launch.distro.id, + ); final process = _processFactory( executable: command.executable, @@ -72,7 +79,7 @@ class LocalTerminalRepository implements SshTerminalRepository { } on AppFailure { rethrow; } catch (error) { - throw AppFailure('Could not start the local Arch Linux shell.', '$error'); + throw AppFailure('Could not start the local shell.', '$error'); } } diff --git a/lib/features/local_shell/data/rootfs_manifest_source.dart b/lib/features/local_shell/data/rootfs_manifest_source.dart deleted file mode 100644 index a658b0e..0000000 --- a/lib/features/local_shell/data/rootfs_manifest_source.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'dart:convert'; - -import 'package:conduit/features/local_shell/domain/rootfs_manifest.dart'; -import 'package:http/http.dart' as http; - -abstract interface class RootfsManifestSource { - Future fetch(); -} - -class EmbeddedRootfsManifestSource implements RootfsManifestSource { - EmbeddedRootfsManifestSource(this.manifest); - - final RootfsManifest manifest; - - @override - Future fetch() async => manifest; -} - -class HttpRootfsManifestSource implements RootfsManifestSource { - HttpRootfsManifestSource(this.manifestUrl, [http.Client? client]) - : _client = client ?? http.Client(); - - final Uri manifestUrl; - final http.Client _client; - - @override - Future fetch() async { - final response = await _client.get(manifestUrl); - if (response.statusCode != 200) { - throw http.ClientException( - 'Manifest request failed (HTTP ${response.statusCode}).', - manifestUrl, - ); - } - final decoded = jsonDecode(response.body); - if (decoded is! Map) { - throw const FormatException('Manifest is not a JSON object.'); - } - return RootfsManifest.fromJson(decoded); - } -} diff --git a/lib/features/local_shell/domain/first_boot_script.dart b/lib/features/local_shell/domain/first_boot_script.dart index cb5ef14..2e1ecc3 100644 --- a/lib/features/local_shell/domain/first_boot_script.dart +++ b/lib/features/local_shell/domain/first_boot_script.dart @@ -1,20 +1,18 @@ class FirstBootConfig { const FirstBootConfig({ - required this.pacmanMirror, - required this.keyringName, + required this.distroName, + required this.updateCommand, + required this.setupCommands, required this.doneMarkerPath, this.nameservers = const ['1.1.1.1', '8.8.8.8'], - this.locales = const ['en_US.UTF-8 UTF-8', 'C.UTF-8 UTF-8'], - this.defaultLocale = 'en_US.UTF-8', }); - final String pacmanMirror; - final String keyringName; + final String distroName; + final String updateCommand; + final List setupCommands; final String doneMarkerPath; final List nameservers; - final List locales; - final String defaultLocale; } class FirstBootScript { @@ -22,8 +20,8 @@ class FirstBootScript { String generate(FirstBootConfig config) { final buffer = StringBuffer() - ..writeln('#!/bin/bash') - ..writeln('set -euo pipefail') + ..writeln('#!/bin/sh') + ..writeln('set -eu') ..writeln() ..writeln('# Idempotent: bail out if first boot already completed.') ..writeln('if [ -f "${config.doneMarkerPath}" ]; then') @@ -41,38 +39,25 @@ class FirstBootScript { ..writeln('cat > /etc/hosts < /etc/pacman.d/mirrorlist", - ) - ..writeln() - ..writeln('# --- locale ---'); - for (final locale in config.locales) { - buffer.writeln("echo '$locale' >> /etc/locale.gen"); + ..writeln('EOF'); + if (config.setupCommands.isNotEmpty) { + buffer + ..writeln() + ..writeln('# --- distro setup ---'); + for (final command in config.setupCommands) { + buffer.writeln(command); + } } buffer - ..writeln('locale-gen') - ..writeln("echo 'LANG=${config.defaultLocale}' > /etc/locale.conf") - ..writeln() - ..writeln('# --- entropy seed (keeps pacman-key from blocking) ---') - ..writeln('mkdir -p /var/lib') - ..writeln('head -c 4096 /dev/urandom > /root/.rnd 2>/dev/null || true') - ..writeln() - ..writeln('# --- pacman keyring ---') - ..writeln('pacman-key --init') - ..writeln('pacman-key --populate ${config.keyringName}') ..writeln() ..writeln('# --- first-login welcome (shown once) ---') ..writeln('mkdir -p /etc/profile.d') ..writeln("cat > /etc/profile.d/conduit-welcome.sh <<'WELCOME'") ..writeln('if [ ! -f "\$HOME/.conduit-welcomed" ]; then') - ..writeln(' echo "Arch Linux - running locally via Conduit."') + ..writeln(' echo "${config.distroName} - running locally via Conduit."') ..writeln( - ' echo "Tip: run pacman -Syu to refresh before installing ' - 'packages."', + ' echo "Tip: run ${config.updateCommand} to refresh before ' + 'installing packages."', ) ..writeln(' echo') ..writeln(' touch "\$HOME/.conduit-welcomed" 2>/dev/null || true') diff --git a/lib/features/local_shell/domain/local_shell_distro.dart b/lib/features/local_shell/domain/local_shell_distro.dart new file mode 100644 index 0000000..82c4e7d --- /dev/null +++ b/lib/features/local_shell/domain/local_shell_distro.dart @@ -0,0 +1,27 @@ +import 'package:conduit/features/local_shell/domain/local_shell_paths.dart'; +import 'package:conduit/features/local_shell/domain/rootfs_manifest.dart'; + +class LocalShellDistro { + const LocalShellDistro({ + required this.id, + required this.name, + required this.updateCommand, + required this.manifest, + this.loginCommand = const ['/bin/bash', '--login'], + this.setupCommands = const [], + }); + + final String id; + final String name; + final String updateCommand; + final RootfsManifest manifest; + final List loginCommand; + final List setupCommands; +} + +class LocalShellLaunch { + const LocalShellLaunch({required this.distro, required this.paths}); + + final LocalShellDistro distro; + final LocalShellPaths paths; +} diff --git a/lib/features/local_shell/domain/local_shell_event.dart b/lib/features/local_shell/domain/local_shell_event.dart index 1238ae2..433b7b5 100644 --- a/lib/features/local_shell/domain/local_shell_event.dart +++ b/lib/features/local_shell/domain/local_shell_event.dart @@ -4,12 +4,6 @@ sealed class LocalShellEvent { const LocalShellEvent(); } -class DeviceUnsupported extends LocalShellEvent { - const DeviceUnsupported(this.reason); - - final String reason; -} - class EnvironmentReady extends LocalShellEvent { const EnvironmentReady({required this.version, this.diskUsageBytes}); @@ -22,7 +16,9 @@ class EnvironmentMissing extends LocalShellEvent { } class InstallRequested extends LocalShellEvent { - const InstallRequested(); + const InstallRequested({required this.distroName}); + + final String distroName; } class DownloadProgressed extends LocalShellEvent { diff --git a/lib/features/local_shell/domain/local_shell_instance.dart b/lib/features/local_shell/domain/local_shell_instance.dart new file mode 100644 index 0000000..36dd184 --- /dev/null +++ b/lib/features/local_shell/domain/local_shell_instance.dart @@ -0,0 +1,14 @@ +class LocalShellInstance { + const LocalShellInstance({ + required this.id, + required this.distroId, + required this.name, + }); + + final String id; + final String distroId; + final String name; + + LocalShellInstance copyWith({String? name}) => + LocalShellInstance(id: id, distroId: distroId, name: name ?? this.name); +} diff --git a/lib/features/local_shell/domain/local_shell_paths.dart b/lib/features/local_shell/domain/local_shell_paths.dart index 4afdc3c..b6898b9 100644 --- a/lib/features/local_shell/domain/local_shell_paths.dart +++ b/lib/features/local_shell/domain/local_shell_paths.dart @@ -2,6 +2,7 @@ import 'package:path/path.dart' as p; class LocalShellPaths { const LocalShellPaths({ + required this.instanceId, required this.nativeLibraryDir, required this.dataDir, this.sharedStorageFeatureEnabled = false, @@ -9,6 +10,8 @@ class LocalShellPaths { this.sharedStorageAccessGranted = false, }); + final String instanceId; + final String nativeLibraryDir; final String dataDir; @@ -28,7 +31,7 @@ class LocalShellPaths { String get xzBinary => p.join(nativeLibraryDir, 'libxzbin.so'); - String get installRoot => p.join(dataDir, 'archlinux'); + String get installRoot => p.join(dataDir, instanceId); String get rootfsDir => p.join(installRoot, 'rootfs'); String get androidSharedMountHostPath => p.join(rootfsDir, 'mnt', 'android'); bool get canMountSharedStorage => diff --git a/lib/features/local_shell/domain/local_shell_state.dart b/lib/features/local_shell/domain/local_shell_state.dart index 8d1548e..ea821e1 100644 --- a/lib/features/local_shell/domain/local_shell_state.dart +++ b/lib/features/local_shell/domain/local_shell_state.dart @@ -22,7 +22,7 @@ enum LocalShellErrorKind { lowDisk, corruptDownload, extractionFailed, - keyringFailed, + configureFailed, unknown, } diff --git a/lib/features/local_shell/domain/local_shell_state_machine.dart b/lib/features/local_shell/domain/local_shell_state_machine.dart index 8357a92..ab67300 100644 --- a/lib/features/local_shell/domain/local_shell_state_machine.dart +++ b/lib/features/local_shell/domain/local_shell_state_machine.dart @@ -6,16 +6,6 @@ class LocalShellStateMachine { LocalShellState reduce(LocalShellState state, LocalShellEvent event) { switch (event) { - case DeviceUnsupported(:final reason): - return LocalShellState( - stage: LocalShellStage.unsupported, - error: LocalShellError(LocalShellErrorKind.unsupportedDevice, reason), - ); - - case (EnvironmentReady() || EnvironmentMissing()) - when state.isUnsupported: - return state; - case EnvironmentReady(:final version, :final diskUsageBytes): return LocalShellState( stage: LocalShellStage.ready, @@ -26,11 +16,11 @@ class LocalShellStateMachine { case EnvironmentMissing(): return LocalShellState.notInstalled; - case InstallRequested(): - return const LocalShellState( + case InstallRequested(:final distroName): + return LocalShellState( stage: LocalShellStage.downloading, progress: 0, - message: 'Downloading Arch Linux…', + message: 'Downloading $distroName…', ); case DownloadProgressed(:final progress): @@ -48,7 +38,7 @@ class LocalShellStateMachine { if (state.stage != LocalShellStage.extracting) return state; return const LocalShellState( stage: LocalShellStage.configuring, - message: 'Configuring pacman & keyring…', + message: 'Running first-time setup…', ); case ConfigureStarted(): diff --git a/lib/features/local_shell/domain/proot_command.dart b/lib/features/local_shell/domain/proot_command.dart index 3732446..22f5012 100644 --- a/lib/features/local_shell/domain/proot_command.dart +++ b/lib/features/local_shell/domain/proot_command.dart @@ -63,6 +63,7 @@ class ProotCommandBuilder { ProotCommand login({ required String rootfsDir, List command = const ['/bin/bash', '--login'], + String promptLabel = 'linux', Map extraEnv = const {}, }) { final env = [ @@ -70,7 +71,7 @@ class ProotCommandBuilder { 'TERM=xterm-256color', 'LANG=C.UTF-8', 'PATH=$_path', - 'PS1=[\\u@archlinux \\W]\\\$ ', + 'PS1=[\\u@$promptLabel \\W]\\\$ ', for (final entry in extraEnv.entries) '${entry.key}=${entry.value}', ]; @@ -96,7 +97,7 @@ class ProotCommandBuilder { } ProotCommand runScript({required String rootfsDir, required String script}) { - return login(rootfsDir: rootfsDir, command: ['/bin/bash', '-lc', script]); + return login(rootfsDir: rootfsDir, command: ['/bin/sh', '-lc', script]); } ProotCommand extractTar({ diff --git a/lib/features/local_shell/domain/rootfs_manifest.dart b/lib/features/local_shell/domain/rootfs_manifest.dart index c61f699..8e450ea 100644 --- a/lib/features/local_shell/domain/rootfs_manifest.dart +++ b/lib/features/local_shell/domain/rootfs_manifest.dart @@ -8,8 +8,6 @@ class RootfsManifest { required this.archiveUrl, required this.sha256, required this.downloadSizeBytes, - required this.pacmanMirror, - this.keyringName = 'archlinuxarm', }); final String version; @@ -18,14 +16,9 @@ class RootfsManifest { final String sha256; final int downloadSizeBytes; - final String pacmanMirror; - - final String keyringName; - factory RootfsManifest.fromJson(Map json) { final url = (json['archiveUrl'] as String?)?.trim() ?? ''; final sha = (json['sha256'] as String?)?.trim().toLowerCase() ?? ''; - final mirror = (json['pacmanMirror'] as String?)?.trim() ?? ''; final version = (json['version'] as String?)?.trim() ?? ''; final size = (json['downloadSizeBytes'] as num?)?.toInt() ?? 0; @@ -44,19 +37,12 @@ class RootfsManifest { if (version.isEmpty) { throw const FormatException('Manifest is missing "version".'); } - if (mirror.isEmpty) { - throw const FormatException('Manifest is missing "pacmanMirror".'); - } return RootfsManifest( version: version, archiveUrl: parsedUrl, sha256: sha, downloadSizeBytes: size, - pacmanMirror: mirror, - keyringName: (json['keyringName'] as String?)?.trim().isNotEmpty == true - ? (json['keyringName'] as String).trim() - : 'archlinuxarm', ); } diff --git a/lib/features/local_shell/local_shell_config.dart b/lib/features/local_shell/local_shell_config.dart index bce4862..59c902d 100644 --- a/lib/features/local_shell/local_shell_config.dart +++ b/lib/features/local_shell/local_shell_config.dart @@ -1,12 +1,158 @@ +import 'package:conduit/features/local_shell/domain/local_shell_distro.dart'; import 'package:conduit/features/local_shell/domain/rootfs_manifest.dart'; -RootfsManifest defaultRootfsManifest() => RootfsManifest( - version: 'archlinux-aarch64-pd-v4.22.1', - archiveUrl: Uri.parse( - 'https://github.com/termux/proot-distro/releases/download/' - 'v4.22.1/archlinux-aarch64-pd-v4.22.1.tar.xz', +const String defaultLocalShellDistroId = 'archlinux'; + +const String _rootfsBaseUrl = + 'https://github.com/gwitko/conduit-rootfs/releases/download/rootfs-pd-v4.37.0'; + +const List _pacmanLocaleSetup = [ + "echo 'en_US.UTF-8 UTF-8' >> /etc/locale.gen", + "echo 'C.UTF-8 UTF-8' >> /etc/locale.gen", + 'locale-gen', + "echo 'LANG=en_US.UTF-8' > /etc/locale.conf", +]; + +const List _debianLocaleSetup = [ + r"sed -i -E 's/#[[:space:]]?(en_US.UTF-8[[:space:]]+UTF-8)/\1/g' " + '/etc/locale.gen', + 'DEBIAN_FRONTEND=noninteractive dpkg-reconfigure locales', +]; + +const List _keyringEntropySeed = [ + 'head -c 4096 /dev/urandom > /root/.rnd 2>/dev/null || true', +]; + +List defaultLocalShellDistros() => [ + LocalShellDistro( + id: 'archlinux', + name: 'Arch Linux', + updateCommand: 'pacman -Syu', + setupCommands: [ + 'mkdir -p /etc/pacman.d', + r"echo 'Server = http://mirror.archlinuxarm.org/$arch/$repo'" + ' > /etc/pacman.d/mirrorlist', + ..._pacmanLocaleSetup, + ..._keyringEntropySeed, + 'pacman-key --init', + 'pacman-key --populate archlinuxarm', + ], + manifest: RootfsManifest( + version: 'archlinux-aarch64-pd-v4.37.0', + archiveUrl: Uri.parse( + '$_rootfsBaseUrl/archlinux-aarch64-pd-v4.37.0.tar.xz', + ), + sha256: + '718151cc4adad701223c689a7e4690cb7710b7b16e9b23617b671856ff04d563', + downloadSizeBytes: 176379228, + ), + ), + LocalShellDistro( + id: 'debian', + name: 'Debian', + updateCommand: 'apt update && apt upgrade', + setupCommands: _debianLocaleSetup, + manifest: RootfsManifest( + version: 'debian-trixie-aarch64-pd-v4.37.0', + archiveUrl: Uri.parse( + '$_rootfsBaseUrl/' + 'debian-trixie-aarch64-pd-v4.37.0.tar.xz', + ), + sha256: + '9bd3b19ff7cd300c7c7bf33124b726eb199f4bab9a3b1472f34749c6d12c9195', + downloadSizeBytes: 35401288, + ), + ), + LocalShellDistro( + id: 'ubuntu', + name: 'Ubuntu', + updateCommand: 'apt update && apt upgrade', + setupCommands: _debianLocaleSetup, + manifest: RootfsManifest( + version: 'ubuntu-questing-aarch64-pd-v4.37.0', + archiveUrl: Uri.parse( + '$_rootfsBaseUrl/' + 'ubuntu-questing-aarch64-pd-v4.37.0.tar.xz', + ), + sha256: + '37e61ce5fd8593a7d10c4e72ebe611adb7e795f7492e4c0bf3a950441c984161', + downloadSizeBytes: 57561884, + ), + ), + LocalShellDistro( + id: 'alpine', + name: 'Alpine Linux', + updateCommand: 'apk update && apk upgrade', + loginCommand: ['/bin/sh', '-l'], + manifest: RootfsManifest( + version: 'alpine-aarch64-pd-v4.37.0', + archiveUrl: Uri.parse('$_rootfsBaseUrl/alpine-aarch64-pd-v4.37.0.tar.xz'), + sha256: + '2bdfb03eae53e6163695f4cd3b86e67ddca78466c879a140e069b1263150599b', + downloadSizeBytes: 3489164, + ), + ), + LocalShellDistro( + id: 'rocky', + name: 'Rocky Linux', + updateCommand: 'dnf upgrade', + manifest: RootfsManifest( + version: 'rocky-aarch64-pd-v4.37.0', + archiveUrl: Uri.parse('$_rootfsBaseUrl/rocky-aarch64-pd-v4.37.0.tar.xz'), + sha256: + '0282a82a75e0b17aa0f72622847ee0bfda85fa84bb6cf49bc72c5515816c47f0', + downloadSizeBytes: 52225336, + ), + ), + LocalShellDistro( + id: 'opensuse', + name: 'openSUSE Leap', + updateCommand: 'zypper refresh && zypper update', + setupCommands: const ['zypper al filesystem'], + manifest: RootfsManifest( + version: 'opensuse-aarch64-pd-v4.37.0', + archiveUrl: Uri.parse( + '$_rootfsBaseUrl/opensuse-aarch64-pd-v4.37.0.tar.xz', + ), + sha256: + '812bbed638f43b81846520bf4283c18da08e19f14714e56fffdc9ccad3c65d7a', + downloadSizeBytes: 47451080, + ), + ), + LocalShellDistro( + id: 'void', + name: 'Void Linux', + updateCommand: 'xbps-install -Su', + setupCommands: const [ + 'usermod --shell /bin/bash root', + 'update-ca-certificates --fresh', + ], + manifest: RootfsManifest( + version: 'void-aarch64-pd-v4.29.0', + archiveUrl: Uri.parse('$_rootfsBaseUrl/void-aarch64-pd-v4.29.0.tar.xz'), + sha256: + '7a7c449b3efe504749e40f556d13812010bccc930a820a56973a0f5fc2f16997', + downloadSizeBytes: 51095528, + ), + ), + LocalShellDistro( + id: 'manjaro', + name: 'Manjaro', + updateCommand: 'pacman -Syu', + setupCommands: [ + ..._pacmanLocaleSetup, + ..._keyringEntropySeed, + 'pacman-key --init', + 'pacman-key --populate', + ], + manifest: RootfsManifest( + version: 'manjaro-aarch64-pd-v4.37.0', + archiveUrl: Uri.parse( + '$_rootfsBaseUrl/manjaro-aarch64-pd-v4.37.0.tar.xz', + ), + sha256: + '90fd86130d440b6d6ed6408b21306189eb41fe07d0026aab836ae203a1c419a4', + downloadSizeBytes: 140621696, + ), ), - sha256: 'b7e4cfb1414a281f90bfd39a503f72f38e03c31b356927972f797988fb48b5b1', - downloadSizeBytes: 149200240, - pacmanMirror: r'http://mirror.archlinuxarm.org/$arch/$repo', -); +]; diff --git a/lib/features/local_shell/local_shell_licenses.dart b/lib/features/local_shell/local_shell_licenses.dart index 875ea37..5eeef82 100644 --- a/lib/features/local_shell/local_shell_licenses.dart +++ b/lib/features/local_shell/local_shell_licenses.dart @@ -28,7 +28,7 @@ const Map _noticeAssets = { }; const String _notice = ''' -The on-device local Arch Linux shell includes Android (aarch64) binaries that +The on-device local Linux shell includes Android (aarch64) binaries that Conduit redistributes but did not create. They are built from pinned Termux package recipes (https://termux.dev) and are used under their respective open-source licenses: @@ -46,9 +46,10 @@ open-source licenses: libandroid-glob ....... BSD-3-Clause libpcre2 .............. BSD-3-Clause WITH PCRE2-exception -The Arch Linux ARM root filesystem is distributed via Termux's proot-distro -(github.com/termux/proot-distro) and maintained by the Arch Linux ARM project -(archlinuxarm.org). +The downloadable root filesystems are packaged via Termux's proot-distro +(github.com/termux/proot-distro) and maintained by their upstream projects: +Arch Linux ARM (archlinuxarm.org), Debian, Ubuntu, Alpine Linux, Rocky Linux, +openSUSE, Void Linux, and Manjaro. Conduit's own source code is Apache-2.0. These bundled components and downloaded rootfs packages are not relicensed by Conduit. diff --git a/lib/features/local_shell/presentation/local_shell_controller.dart b/lib/features/local_shell/presentation/local_shell_controller.dart index e178430..914eb93 100644 --- a/lib/features/local_shell/presentation/local_shell_controller.dart +++ b/lib/features/local_shell/presentation/local_shell_controller.dart @@ -3,67 +3,195 @@ import 'dart:io'; import 'package:conduit/core/app_failure.dart'; import 'package:conduit/features/hosts/domain/saved_host.dart'; import 'package:conduit/features/local_shell/data/first_boot_runner.dart'; +import 'package:conduit/features/local_shell/data/local_shell_instance_store.dart'; import 'package:conduit/features/local_shell/data/local_shell_platform.dart'; import 'package:conduit/features/local_shell/data/local_shell_store.dart'; import 'package:conduit/features/local_shell/data/rootfs_downloader.dart'; import 'package:conduit/features/local_shell/data/rootfs_extractor.dart'; -import 'package:conduit/features/local_shell/data/rootfs_manifest_source.dart'; +import 'package:conduit/features/local_shell/domain/local_shell_distro.dart'; import 'package:conduit/features/local_shell/domain/local_shell_event.dart'; +import 'package:conduit/features/local_shell/domain/local_shell_instance.dart'; import 'package:conduit/features/local_shell/domain/local_shell_paths.dart'; import 'package:conduit/features/local_shell/domain/local_shell_state.dart'; import 'package:conduit/features/local_shell/domain/local_shell_state_machine.dart'; import 'package:conduit/features/local_shell/local_shell_config.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; +import 'package:path/path.dart' as p; const String localShellHostId = '__conduit_local_shell__'; +String localShellHostIdFor(String instanceId) => '$localShellHostId$instanceId'; + +String? localShellInstanceIdFromHostId(String hostId) { + if (!hostId.startsWith(localShellHostId)) return null; + final rest = hostId.substring(localShellHostId.length); + final separator = rest.indexOf('#'); + return separator == -1 ? rest : rest.substring(0, separator); +} + class LocalShellController extends ChangeNotifier { LocalShellController({ - RootfsManifestSource? manifestSource, + List? catalog, this.platform = const LocalShellPlatform(), this.httpClient, this.machine = const LocalShellStateMachine(), - }) : manifestSource = - manifestSource ?? - EmbeddedRootfsManifestSource(defaultRootfsManifest()); + }) : catalog = catalog ?? defaultLocalShellDistros(); - final RootfsManifestSource manifestSource; + final List catalog; final LocalShellPlatform platform; final http.Client? httpClient; final LocalShellStateMachine machine; - LocalShellState _state = LocalShellState.initial; - LocalShellState get state => _state; - + final Map _states = {}; + List _instances = []; LocalShellEnvironment? _environment; - LocalShellPaths? _paths; + String? _unsupportedMessage; + bool _probed = false; + String? _lastOpenedInstanceId; Future? _probeFuture; + List get instances => List.unmodifiable(_instances); + + bool get isChecking => !_probed; + + bool get isUnsupported => _unsupportedMessage != null; + + String? get unsupportedMessage => _unsupportedMessage; + + bool get anyBusy => _states.values.any((state) => state.isBusy); + + LocalShellState stateFor(String instanceId) => + _states[instanceId] ?? LocalShellState.initial; + + LocalShellState get state { + final message = _unsupportedMessage; + if (message != null) { + return LocalShellState( + stage: LocalShellStage.unsupported, + error: LocalShellError(LocalShellErrorKind.unsupportedDevice, message), + ); + } + if (!_probed) return LocalShellState.initial; + final instance = defaultInstance; + if (instance == null) return LocalShellState.notInstalled; + return stateFor(instance.id); + } + + LocalShellDistro? distroById(String distroId) { + for (final distro in catalog) { + if (distro.id == distroId) return distro; + } + return null; + } + + LocalShellInstance? instanceById(String instanceId) { + for (final instance in _instances) { + if (instance.id == instanceId) return instance; + } + return null; + } + + LocalShellInstance? get defaultInstance { + for (final instance in _instances) { + if (stateFor(instance.id).isBusy) return instance; + } + final lastOpened = _lastOpenedInstanceId; + if (lastOpened != null) { + final instance = instanceById(lastOpened); + if (instance != null && stateFor(instance.id).isReady) return instance; + } + for (final instance in _instances) { + if (stateFor(instance.id).isReady) return instance; + } + return _instances.isEmpty ? null : _instances.first; + } + bool get sharedStorageAccessGranted => - _paths?.sharedStorageAccessGranted ?? false; + _environment?.sharedStorageAccessGranted ?? false; bool get sharedStorageFeatureEnabled => - _paths?.sharedStorageFeatureEnabled ?? false; + _environment?.sharedStorageFeatureEnabled ?? false; + + SavedHost localHost( + LocalShellInstance instance, { + int sessionNumber = 1, + }) => SavedHost.localShell( + id: + '${localShellHostIdFor(instance.id)}' + '${sessionNumber <= 1 ? '' : '#${DateTime.now().microsecondsSinceEpoch}'}', + name: sessionNumber <= 1 + ? instance.name + : '${instance.name} ($sessionNumber)', + ); + + LocalShellInstanceStore? get _instanceStore { + final env = _environment; + if (env == null || !env.isUsable) return null; + return LocalShellInstanceStore(dataDir: env.filesDir, catalog: catalog); + } - SavedHost localHost() => SavedHost.localShell(id: localShellHostId); + LocalShellPaths? _pathsFor(String instanceId) { + final env = _environment; + if (env == null || !env.isUsable) return null; + return LocalShellPaths( + instanceId: instanceId, + nativeLibraryDir: env.nativeLibraryDir, + dataDir: env.filesDir, + sharedStorageFeatureEnabled: env.sharedStorageFeatureEnabled, + sharedStorageDir: env.sharedStorageDir, + sharedStorageAccessGranted: env.sharedStorageAccessGranted, + ); + } - Future requirePaths() async { - final paths = _paths; - if (paths == null) { + Future requireLaunch(String hostId) async { + final instanceId = localShellInstanceIdFromHostId(hostId) ?? hostId; + final instance = instanceById(instanceId); + final distro = instance == null ? null : distroById(instance.distroId); + final paths = _pathsFor(instanceId); + if (distro == null || paths == null) { throw const AppFailure('The local shell is not installed.'); } - return paths; + return LocalShellLaunch(distro: distro, paths: paths); + } + + String? get _lastOpenedFilePath { + final env = _environment; + if (env == null || env.filesDir.isEmpty) return null; + return p.join(env.filesDir, '.local_shell_last_distro'); } - void _dispatch(LocalShellEvent event) { - final next = machine.reduce(_state, event); - if (next == _state) return; - _state = next; + Future markOpened(String instanceId) async { + _lastOpenedInstanceId = instanceId; + notifyListeners(); + final path = _lastOpenedFilePath; + if (path == null) return; + try { + await File(path).writeAsString(instanceId); + } catch (_) {} + } + + Future _loadLastOpened() async { + final path = _lastOpenedFilePath; + if (path == null) return; + try { + final file = File(path); + if (await file.exists()) { + final id = (await file.readAsString()).trim(); + if (id.isNotEmpty) _lastOpenedInstanceId = id; + } + } catch (_) {} + } + + void _dispatch(String instanceId, LocalShellEvent event) { + final current = stateFor(instanceId); + final next = machine.reduce(current, event); + if (next == current) return; + _states[instanceId] = next; notifyListeners(); } Future refresh() async { - if (_state.isBusy) return; + if (anyBusy) return; final existingProbe = _probeFuture; if (existingProbe != null) return existingProbe; @@ -83,98 +211,166 @@ class LocalShellController extends ChangeNotifier { final env = await platform.load(); _environment = env; if (env == null) { - _paths = null; - _dispatch( - const DeviceUnsupported( - 'The local shell is only available on Android.', - ), - ); + _setUnsupported('The local shell is only available on Android.'); return; } if (!env.isUsable) { - _paths = null; - _dispatch( - const DeviceUnsupported( - 'The local shell requires a 64-bit ARM (arm64-v8a) device.', - ), + _setUnsupported( + 'The local shell requires a 64-bit ARM (arm64-v8a) device.', ); return; } - final paths = LocalShellPaths( - nativeLibraryDir: env.nativeLibraryDir, - dataDir: env.filesDir, - sharedStorageFeatureEnabled: env.sharedStorageFeatureEnabled, - sharedStorageDir: env.sharedStorageDir, - sharedStorageAccessGranted: env.sharedStorageAccessGranted, - ); - _paths = paths; - final store = LocalShellStore(paths); - if (await store.isConfigured()) { - _dispatch( - EnvironmentReady( - version: await store.installedVersion() ?? 'unknown', - diskUsageBytes: await store.diskUsageBytes(), - ), - ); - } else { - _dispatch(const EnvironmentMissing()); + _unsupportedMessage = null; + await _loadLastOpened(); + final store = _instanceStore; + if (store != null) { + _instances = await store.discover(); + } + for (final instance in _instances) { + final paths = _pathsFor(instance.id); + if (paths == null) continue; + final installStore = LocalShellStore(paths); + if (await installStore.isConfigured()) { + _dispatch( + instance.id, + EnvironmentReady( + version: await installStore.installedVersion() ?? 'unknown', + diskUsageBytes: await installStore.diskUsageBytes(), + ), + ); + } else { + _dispatch(instance.id, const EnvironmentMissing()); + } } + _probed = true; + notifyListeners(); } catch (error) { - _dispatch(InstallFailed(_mapError(error))); + _probed = true; + for (final instance in _instances) { + _dispatch(instance.id, InstallFailed(_mapError(error))); + } + notifyListeners(); } } - Future install() async { - if (_state.isChecking) { - await refresh(); - } - if (!_state.canInstall) return; - if (_paths == null || _environment == null) { - await refresh(); + void _setUnsupported(String message) { + _probed = true; + _unsupportedMessage = message; + _instances = []; + _states.clear(); + notifyListeners(); + } + + Future installNew(String distroId, {String? name}) async { + if (anyBusy) return; + if (!_probed) await refresh(); + final distro = distroById(distroId); + final store = _instanceStore; + if (distro == null || store == null) return; + + final instance = await store.createInstance(distro, name: name); + _instances = [..._instances, instance]; + notifyListeners(); + await _runInstall(instance); + } + + Future install(String instanceId) async { + if (anyBusy || !stateFor(instanceId).canInstall) return; + final instance = instanceById(instanceId); + if (instance == null) return; + await _runInstall(instance); + } + + Future reinstall(String instanceId) async { + if (anyBusy) return; + final instance = instanceById(instanceId); + final paths = _pathsFor(instanceId); + if (instance == null || paths == null) return; + try { + await LocalShellStore(paths).wipe(); + } catch (_) {} + _dispatch(instanceId, const ResetRequested()); + await _runInstall(instance); + } + + Future remove(String instanceId) async { + final paths = _pathsFor(instanceId); + if (paths == null || anyBusy) return; + try { + await LocalShellStore(paths).wipe(); + } catch (_) {} + _instances = [ + for (final instance in _instances) + if (instance.id != instanceId) instance, + ]; + _states.remove(instanceId); + if (_lastOpenedInstanceId == instanceId) { + _lastOpenedInstanceId = null; } - final paths = _paths; - final env = _environment; - if (paths == null || env == null || !env.isUsable) { - _dispatch( - const DeviceUnsupported( - 'The local shell requires a 64-bit ARM (arm64-v8a) device.', - ), + notifyListeners(); + } + + Future rename(String instanceId, String name) async { + final trimmed = name.trim(); + final instance = instanceById(instanceId); + final store = _instanceStore; + if (instance == null || store == null || trimmed.isEmpty) return; + final renamed = instance.copyWith(name: trimmed); + _instances = [ + for (final entry in _instances) entry.id == instanceId ? renamed : entry, + ]; + notifyListeners(); + try { + await store.writeMeta(renamed); + } catch (_) {} + } + + Future _runInstall(LocalShellInstance instance) async { + final distro = distroById(instance.distroId); + final paths = _pathsFor(instance.id); + final instanceStore = _instanceStore; + if (distro == null || paths == null || instanceStore == null) { + _setUnsupported( + 'The local shell requires a 64-bit ARM (arm64-v8a) device.', ); return; } - _dispatch(const InstallRequested()); + _dispatch(instance.id, InstallRequested(distroName: instance.name)); try { - final manifest = await manifestSource.fetch(); + final manifest = distro.manifest; final store = LocalShellStore(paths); await store.prepareDirectories(); + await instanceStore.writeMeta(instance); await HttpRootfsDownloader(httpClient).download( manifest: manifest, destination: paths.downloadPath, - onProgress: (progress) => _dispatch(DownloadProgressed(progress)), + onProgress: (progress) => + _dispatch(instance.id, DownloadProgressed(progress)), ); - _dispatch(const DownloadFinished()); + _dispatch(instance.id, const DownloadFinished()); await store.resetRootfs(); await ProotRootfsExtractor(paths).extract(); await store.deleteDownload(); - _dispatch(const ExtractFinished()); + _dispatch(instance.id, const ExtractFinished()); - _dispatch(const ConfigureStarted()); - await ProotFirstBootRunner(paths).run(manifest); + _dispatch(instance.id, const ConfigureStarted()); + await ProotFirstBootRunner(paths).run(distro); await store.writeVersion(manifest.version); _dispatch( + instance.id, InstallSucceeded( version: manifest.version, diskUsageBytes: await store.diskUsageBytes(), ), ); } catch (error) { - _dispatch(InstallFailed(_mapError(error))); + _dispatch(instance.id, InstallFailed(_mapError(error))); } } @@ -183,21 +379,6 @@ class LocalShellController extends ChangeNotifier { await refresh(); } - Future reinstall() async { - if (_state.isBusy) return; - await reset(); - await install(); - } - - Future reset() async { - final paths = _paths; - if (paths == null || _state.isBusy) return; - try { - await LocalShellStore(paths).wipe(); - } catch (_) {} - _dispatch(const ResetRequested()); - } - LocalShellError _mapError(Object error) { if (error is DownloadException) { return LocalShellError(switch (error.kind) { @@ -214,17 +395,14 @@ class LocalShellController extends ChangeNotifier { ); } if (error is FirstBootException) { - return LocalShellError(LocalShellErrorKind.keyringFailed, error.message); + return LocalShellError( + LocalShellErrorKind.configureFailed, + error.message, + ); } if (error is http.ClientException || error is SocketException) { return LocalShellError(LocalShellErrorKind.network, '$error'); } - if (error is FormatException) { - return LocalShellError( - LocalShellErrorKind.network, - 'Invalid rootfs manifest: ${error.message}', - ); - } return LocalShellError(LocalShellErrorKind.unknown, '$error'); } } diff --git a/lib/features/local_shell/presentation/local_shell_page.dart b/lib/features/local_shell/presentation/local_shell_instance_page.dart similarity index 55% rename from lib/features/local_shell/presentation/local_shell_page.dart rename to lib/features/local_shell/presentation/local_shell_instance_page.dart index 5e6a7c9..412762b 100644 --- a/lib/features/local_shell/presentation/local_shell_page.dart +++ b/lib/features/local_shell/presentation/local_shell_instance_page.dart @@ -1,28 +1,33 @@ import 'dart:async'; +import 'package:conduit/features/local_shell/domain/local_shell_instance.dart'; import 'package:conduit/features/local_shell/domain/local_shell_state.dart'; import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; +import 'package:conduit/features/local_shell/presentation/local_shell_setup_page.dart'; import 'package:flutter/material.dart'; -class LocalShellPage extends StatefulWidget { - const LocalShellPage({ +class LocalShellInstancePage extends StatefulWidget { + const LocalShellInstancePage({ required this.controller, + required this.instanceId, required this.onOpenSession, - required this.onCloseSession, + required this.onCloseSessions, super.key, }); final LocalShellController controller; - final Future Function() onOpenSession; + final String instanceId; - final Future Function() onCloseSession; + final Future Function(LocalShellInstance instance) onOpenSession; + + final Future Function(String instanceId) onCloseSessions; @override - State createState() => _LocalShellPageState(); + State createState() => _LocalShellInstancePageState(); } -class _LocalShellPageState extends State { +class _LocalShellInstancePageState extends State { @override void initState() { super.initState(); @@ -31,68 +36,116 @@ class _LocalShellPageState extends State { }); } + LocalShellInstance? get _instance => + widget.controller.instanceById(widget.instanceId); + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Local shell')), - body: SafeArea( - child: ListenableBuilder( - listenable: widget.controller, - builder: (context, _) { - return SingleChildScrollView( + return ListenableBuilder( + listenable: widget.controller, + builder: (context, _) { + final instance = _instance; + if (instance == null) { + return const Scaffold(body: SizedBox.shrink()); + } + final state = widget.controller.stateFor(instance.id); + return Scaffold( + appBar: AppBar( + title: Text(instance.name), + actions: [ + IconButton( + tooltip: 'Rename', + onPressed: state.isBusy ? null : () => _rename(instance), + icon: const Icon(Icons.edit_outlined), + ), + ], + ), + body: SafeArea( + child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildContent(context, widget.controller.state), + _buildContent(context, instance, state), const SizedBox(height: 32), const _CreditFooter(), ], ), - ); - }, - ), - ), + ), + ), + ); + }, ); } - Widget _buildContent(BuildContext context, LocalShellState state) { + Widget _buildContent( + BuildContext context, + LocalShellInstance instance, + LocalShellState state, + ) { + final distroName = + widget.controller.distroById(instance.distroId)?.name ?? + instance.distroId; switch (state.stage) { case LocalShellStage.checking: - return const _Checking(); - case LocalShellStage.unsupported: - return _Unsupported(message: state.error?.message); - case LocalShellStage.notInstalled: - return _NotInstalled(onInstall: widget.controller.install); + return const Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [SizedBox(height: 8), LinearProgressIndicator()], + ); case LocalShellStage.downloading: case LocalShellStage.extracting: case LocalShellStage.configuring: - return _Installing(state: state); + return _Installing(state: state, distroName: distroName); case LocalShellStage.failed: - return _Failed(error: state.error, onRetry: widget.controller.install); + return _Failed( + error: state.error, + onRetry: () => unawaited(widget.controller.install(instance.id)), + onRemove: () => _confirmRemove(instance), + ); + case LocalShellStage.notInstalled: + return _Incomplete( + onResume: () => unawaited(widget.controller.install(instance.id)), + onRemove: () => _confirmRemove(instance), + ); case LocalShellStage.ready: return _Ready( state: state, + distroName: distroName, + updateCommand: widget.controller + .distroById(instance.distroId) + ?.updateCommand, sharedStorageFeatureEnabled: widget.controller.sharedStorageFeatureEnabled, sharedStorageAccessGranted: widget.controller.sharedStorageAccessGranted, - onOpen: () => unawaited(widget.onOpenSession()), - onReinstall: () => _confirmReinstall(context), - onReset: () => _confirmReset(context), + onOpen: () => unawaited(widget.onOpenSession(instance)), + onReinstall: () => _confirmReinstall(instance), + onRemove: () => _confirmRemove(instance), ); + case LocalShellStage.unsupported: + return const SizedBox.shrink(); + } + } + + Future _rename(LocalShellInstance instance) async { + final name = await showDialog( + context: context, + builder: (context) => _RenameDialog(initialName: instance.name), + ); + if (name != null && name.trim().isNotEmpty) { + await widget.controller.rename(instance.id, name); } } - Future _confirmReinstall(BuildContext context) async { + Future _confirmReinstall(LocalShellInstance instance) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Reinstall Arch Linux?'), - content: const Text( - 'This wipes the current environment - including anything you ' - 'installed with pacman - and downloads a fresh image. Any open ' - 'local shell tab will be closed first.', + title: Text('Reinstall ${instance.name}?'), + content: Text( + 'This wipes this environment - including any packages you ' + 'installed - and downloads a fresh image. Any open ' + '${instance.name} tabs will be closed first.', ), actions: [ TextButton( @@ -107,20 +160,20 @@ class _LocalShellPageState extends State { ), ); if (confirmed == true) { - await widget.onCloseSession(); - await widget.controller.reinstall(); + await widget.onCloseSessions(instance.id); + await widget.controller.reinstall(instance.id); } } - Future _confirmReset(BuildContext context) async { + Future _confirmRemove(LocalShellInstance instance) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Remove local shell?'), - content: const Text( - 'This deletes the installed Arch Linux environment and everything ' - 'in it. Any open local shell tab will be closed first. You can ' - 'reinstall it later.', + title: Text('Remove ${instance.name}?'), + content: Text( + 'This deletes this environment and everything in it. Any open ' + '${instance.name} tabs will be closed first. Other shells are ' + 'not affected.', ), actions: [ TextButton( @@ -135,77 +188,134 @@ class _LocalShellPageState extends State { ), ); if (confirmed == true) { - await widget.onCloseSession(); - await widget.controller.reset(); + await widget.onCloseSessions(instance.id); + await widget.controller.remove(instance.id); + if (mounted) Navigator.of(context).pop(); } } } -class _Checking extends StatelessWidget { - const _Checking(); +class _RenameDialog extends StatefulWidget { + const _RenameDialog({required this.initialName}); + + final String initialName; @override - Widget build(BuildContext context) { - return const Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _Hero( - icon: Icons.search_rounded, - title: 'Checking local shell', - body: 'Looking for an installed Arch Linux environment.', - ), - SizedBox(height: 24), - LinearProgressIndicator(), - ], - ); - } + State<_RenameDialog> createState() => _RenameDialogState(); } -class _Unsupported extends StatelessWidget { - const _Unsupported({this.message}); +class _RenameDialogState extends State<_RenameDialog> { + late final TextEditingController _controller = TextEditingController( + text: widget.initialName, + ); - final String? message; + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return _Hero( - icon: Icons.phonelink_erase_outlined, - title: 'Not available on this device', - body: - message ?? - 'The local Arch Linux shell needs a 64-bit ARM Android device.', + return AlertDialog( + title: const Text('Rename shell'), + content: TextField( + controller: _controller, + autofocus: true, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration(labelText: 'Name'), + onSubmitted: (value) => Navigator.of(context).pop(value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(_controller.text), + child: const Text('Rename'), + ), + ], ); } } -class _NotInstalled extends StatelessWidget { - const _NotInstalled({required this.onInstall}); +class _Ready extends StatelessWidget { + const _Ready({ + required this.state, + required this.distroName, + required this.updateCommand, + required this.sharedStorageFeatureEnabled, + required this.sharedStorageAccessGranted, + required this.onOpen, + required this.onReinstall, + required this.onRemove, + }); - final Future Function() onInstall; + final LocalShellState state; + final String distroName; + final String? updateCommand; + final bool sharedStorageFeatureEnabled; + final bool sharedStorageAccessGranted; + final VoidCallback onOpen; + final VoidCallback onReinstall; + final VoidCallback onRemove; @override Widget build(BuildContext context) { + final theme = Theme.of(context); + final storageStatus = !sharedStorageFeatureEnabled + ? 'Full build only' + : sharedStorageAccessGranted + ? '/mnt/android' + : 'Permission needed'; + final storageHint = sharedStorageFeatureEnabled + ? 'Grant file access to mount phone storage at /mnt/android.' + : 'Phone storage mounting is available in the full build.'; + final updateHint = updateCommand == null + ? '' + : 'Update packages from inside the shell with $updateCommand . '; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const _Hero( - icon: Icons.terminal_rounded, - title: 'Run Arch Linux on your device', - body: - 'Install a full Arch Linux ARM userland with pacman, running ' - 'locally through proot. The image downloads on first use.', + _Hero( + icon: Icons.check_circle_outline_rounded, + title: '$distroName is ready', + body: 'Open a shell, or run several sessions side by side.', ), const SizedBox(height: 24), FilledButton.icon( - onPressed: onInstall, - icon: const Icon(Icons.download_rounded), - label: const Text('Install Arch Linux'), + onPressed: onOpen, + icon: const Icon(Icons.terminal_rounded), + label: const Text('New session'), + ), + const SizedBox(height: 24), + _InfoRow(label: 'Distribution', value: distroName), + _InfoRow(label: 'Version', value: state.installedVersion ?? 'unknown'), + _InfoRow( + label: 'Disk usage', + value: formatLocalShellBytes(state.diskUsageBytes), ), - const SizedBox(height: 12), + _InfoRow(label: 'Android files', value: storageStatus), + const SizedBox(height: 16), Text( - 'Downloads several hundred MB and uses ~1.5 GB once updated. ' - 'Wi-Fi recommended.', - style: Theme.of(context).textTheme.bodySmall, + '$updateHint$storageHint', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: onReinstall, + icon: const Icon(Icons.refresh_rounded), + label: const Text('Reinstall'), + ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: onRemove, + style: TextButton.styleFrom(foregroundColor: theme.colorScheme.error), + icon: const Icon(Icons.delete_outline_rounded), + label: const Text('Remove'), ), ], ); @@ -213,9 +323,10 @@ class _NotInstalled extends StatelessWidget { } class _Installing extends StatelessWidget { - const _Installing({required this.state}); + const _Installing({required this.state, required this.distroName}); final LocalShellState state; + final String distroName; @override Widget build(BuildContext context) { @@ -225,7 +336,7 @@ class _Installing extends StatelessWidget { children: [ _Hero( icon: Icons.settings_suggest_outlined, - title: 'Setting up Arch Linux', + title: 'Setting up $distroName', body: state.message ?? 'Working…', ), const SizedBox(height: 24), @@ -243,10 +354,15 @@ class _Installing extends StatelessWidget { } class _Failed extends StatelessWidget { - const _Failed({required this.error, required this.onRetry}); + const _Failed({ + required this.error, + required this.onRetry, + required this.onRemove, + }); final LocalShellError? error; - final Future Function() onRetry; + final VoidCallback onRetry; + final VoidCallback onRemove; @override Widget build(BuildContext context) { @@ -264,6 +380,15 @@ class _Failed extends StatelessWidget { icon: const Icon(Icons.refresh_rounded), label: const Text('Try again'), ), + const SizedBox(height: 8), + TextButton.icon( + onPressed: onRemove, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), + icon: const Icon(Icons.delete_outline_rounded), + label: const Text('Remove'), + ), ], ); } @@ -274,82 +399,45 @@ class _Failed extends StatelessWidget { LocalShellErrorKind.lowDisk => 'Not enough storage', LocalShellErrorKind.corruptDownload => 'Download was corrupted', LocalShellErrorKind.extractionFailed => 'Could not unpack the image', - LocalShellErrorKind.keyringFailed => 'Configuration failed', + LocalShellErrorKind.configureFailed => 'Configuration failed', LocalShellErrorKind.unsupportedDevice => 'Not available on this device', LocalShellErrorKind.unknown || null => 'Setup failed', }; } } -class _Ready extends StatelessWidget { - const _Ready({ - required this.state, - required this.sharedStorageFeatureEnabled, - required this.sharedStorageAccessGranted, - required this.onOpen, - required this.onReinstall, - required this.onReset, - }); +class _Incomplete extends StatelessWidget { + const _Incomplete({required this.onResume, required this.onRemove}); - final LocalShellState state; - final bool sharedStorageFeatureEnabled; - final bool sharedStorageAccessGranted; - final VoidCallback onOpen; - final VoidCallback onReinstall; - final VoidCallback onReset; + final VoidCallback onResume; + final VoidCallback onRemove; @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final storageStatus = !sharedStorageFeatureEnabled - ? 'Full build only' - : sharedStorageAccessGranted - ? '/mnt/android' - : 'Permission needed'; - final storageHint = sharedStorageFeatureEnabled - ? 'Grant file access to mount phone storage at /mnt/android.' - : 'Phone storage mounting is available in the full build.'; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const _Hero( - icon: Icons.check_circle_outline_rounded, - title: 'Arch Linux is ready', - body: 'Open a local shell and use pacman like any other terminal.', + icon: Icons.downloading_rounded, + title: 'Setup is incomplete', + body: + 'This shell was added but its image never finished installing. ' + 'Resume to pick up where it left off, or remove it.', ), const SizedBox(height: 24), FilledButton.icon( - onPressed: onOpen, - icon: const Icon(Icons.terminal_rounded), - label: const Text('Open shell'), - ), - const SizedBox(height: 24), - _InfoRow(label: 'Version', value: state.installedVersion ?? 'unknown'), - _InfoRow( - label: 'Disk usage', - value: _formatBytes(state.diskUsageBytes), - ), - _InfoRow(label: 'Android files', value: storageStatus), - const SizedBox(height: 16), - Text( - 'Update packages from inside the shell with pacman -Syu . ' - '$storageHint', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: onReinstall, - icon: const Icon(Icons.refresh_rounded), - label: const Text('Reinstall'), + onPressed: onResume, + icon: const Icon(Icons.download_rounded), + label: const Text('Resume install'), ), const SizedBox(height: 8), TextButton.icon( - onPressed: onReset, - style: TextButton.styleFrom(foregroundColor: theme.colorScheme.error), + onPressed: onRemove, + style: TextButton.styleFrom( + foregroundColor: Theme.of(context).colorScheme.error, + ), icon: const Icon(Icons.delete_outline_rounded), - label: const Text('Remove local shell'), + label: const Text('Remove'), ), ], ); @@ -428,9 +516,10 @@ class _CreditFooter extends StatelessWidget { Divider(color: theme.colorScheme.outlineVariant), const SizedBox(height: 8), Text( - 'The local shell uses proot and an Arch Linux ARM image packaged ' - 'through Termux. Conduit redistributes the bundled tools under ' - 'their own open-source licenses.', + 'The local shell uses proot and root filesystem images packaged ' + 'through Termux, maintained by their upstream distributions. ' + 'Conduit redistributes the bundled tools under their own ' + 'open-source licenses.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -451,16 +540,3 @@ class _CreditFooter extends StatelessWidget { ); } } - -String _formatBytes(int? bytes) { - if (bytes == null || bytes <= 0) return 'unknown'; - const units = ['B', 'KB', 'MB', 'GB']; - var value = bytes.toDouble(); - var unit = 0; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit++; - } - final fixed = unit == 0 ? value.toStringAsFixed(0) : value.toStringAsFixed(1); - return '$fixed ${units[unit]}'; -} diff --git a/lib/features/local_shell/presentation/local_shell_setup_page.dart b/lib/features/local_shell/presentation/local_shell_setup_page.dart new file mode 100644 index 0000000..dfbf9a0 --- /dev/null +++ b/lib/features/local_shell/presentation/local_shell_setup_page.dart @@ -0,0 +1,232 @@ +import 'package:conduit/features/local_shell/domain/local_shell_distro.dart'; +import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; +import 'package:flutter/material.dart'; + +class LocalShellSetupRequest { + const LocalShellSetupRequest({required this.distroId, required this.name}); + + final String distroId; + final String name; +} + +class LocalShellSetupPage extends StatefulWidget { + const LocalShellSetupPage({required this.controller, super.key}); + + final LocalShellController controller; + + @override + State createState() => _LocalShellSetupPageState(); +} + +class _LocalShellSetupPageState extends State { + late String _distroId; + late final TextEditingController _nameController; + bool _nameEdited = false; + + @override + void initState() { + super.initState(); + _distroId = widget.controller.catalog.first.id; + _nameController = TextEditingController(text: _suggestedName(_distroId)); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + String _suggestedName(String distroId) { + final distro = widget.controller.distroById(distroId); + if (distro == null) return ''; + final taken = widget.controller.instances + .map((instance) => instance.name) + .toSet(); + if (!taken.contains(distro.name)) return distro.name; + var suffix = 2; + while (taken.contains('${distro.name} $suffix')) { + suffix += 1; + } + return '${distro.name} $suffix'; + } + + void _selectDistro(String distroId) { + setState(() { + _distroId = distroId; + if (!_nameEdited) { + _nameController.text = _suggestedName(distroId); + } + }); + } + + void _submit() { + final name = _nameController.text.trim(); + Navigator.of(context).pop( + LocalShellSetupRequest( + distroId: _distroId, + name: name.isEmpty ? _suggestedName(_distroId) : name, + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final selected = widget.controller.distroById(_distroId); + return Scaffold( + appBar: AppBar(title: const Text('New local shell')), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Run Linux on this phone', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + 'A full Linux userland running locally through proot - no ' + 'server, no root. Pick a distribution; you can set up as ' + 'many shells as you like, including several of the same ' + 'distribution.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + for (final distro in widget.controller.catalog) ...[ + _DistroOption( + distro: distro, + selected: distro.id == _distroId, + onTap: () => _selectDistro(distro.id), + ), + const SizedBox(height: 8), + ], + const SizedBox(height: 16), + TextField( + controller: _nameController, + onChanged: (_) => _nameEdited = true, + textCapitalization: TextCapitalization.words, + decoration: const InputDecoration( + labelText: 'Name', + helperText: 'Shown on the home screen and terminal tabs.', + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _submit, + icon: const Icon(Icons.download_rounded), + label: Text( + selected == null + ? 'Install' + : 'Install · ${formatLocalShellBytes(selected.manifest.downloadSizeBytes)}', + ), + ), + const SizedBox(height: 12), + Text( + 'The image downloads once and unpacks on your device. ' + 'Wi-Fi recommended.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _DistroOption extends StatelessWidget { + const _DistroOption({ + required this.distro, + required this.selected, + required this.onTap, + }); + + final LocalShellDistro distro; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: selected + ? colorScheme.primaryContainer.withValues(alpha: 0.35) + : colorScheme.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected + ? colorScheme.primary.withValues(alpha: 0.55) + : colorScheme.outlineVariant, + ), + ), + padding: const EdgeInsets.fromLTRB(14, 12, 12, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + distro.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + const SizedBox(height: 2), + Text( + 'Download ' + '${formatLocalShellBytes(distro.manifest.downloadSizeBytes)}', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + height: 1.25, + ), + ), + ], + ), + ), + Icon( + selected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_off_rounded, + size: 20, + color: selected + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} + +String formatLocalShellBytes(int? bytes) { + if (bytes == null || bytes <= 0) return 'unknown'; + const units = ['B', 'KB', 'MB', 'GB']; + var value = bytes.toDouble(); + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + final fixed = unit == 0 ? value.toStringAsFixed(0) : value.toStringAsFixed(1); + return '$fixed ${units[unit]}'; +} diff --git a/lib/features/local_shell/presentation/widgets/local_shell_card.dart b/lib/features/local_shell/presentation/widgets/local_shell_card.dart deleted file mode 100644 index a1bc348..0000000 --- a/lib/features/local_shell/presentation/widgets/local_shell_card.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'package:conduit/features/local_shell/domain/local_shell_state.dart'; -import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; -import 'package:flutter/material.dart'; - -class LocalShellCard extends StatelessWidget { - const LocalShellCard({ - required this.controller, - required this.active, - required this.onOpenSession, - required this.onManage, - super.key, - }); - - final LocalShellController controller; - final bool active; - final Future Function() onOpenSession; - final VoidCallback onManage; - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: controller, - builder: (context, _) { - final state = controller.state; - if (state.isUnsupported) return const SizedBox.shrink(); - return Padding( - padding: const EdgeInsets.fromLTRB(18, 0, 18, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _sectionHeader(context), - const SizedBox(height: 10), - _card(context, state), - ], - ), - ); - }, - ); - } - - Widget _sectionHeader(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Device', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 3), - Text( - 'Run commands locally, no server required.', - style: theme.textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - height: 1.25, - ), - ), - ], - ); - } - - Widget _card(BuildContext context, LocalShellState state) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final ready = state.isReady; - final highlight = ready && active; - - return Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(14), - onTap: () => _onTap(state), - child: Container( - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: highlight - ? colorScheme.primary.withValues(alpha: 0.55) - : colorScheme.outlineVariant, - ), - ), - padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), - child: Row( - children: [ - _Avatar(ready: ready), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Local shell', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w800, - height: 1.1, - ), - ), - const SizedBox(height: 2), - Text( - _statusLine(state), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: state.stage == LocalShellStage.failed - ? colorScheme.error - : colorScheme.onSurfaceVariant, - fontSize: 12, - height: 1.25, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - _trailing(context, state), - ], - ), - ), - ), - ); - } - - Widget _trailing(BuildContext context, LocalShellState state) { - final colorScheme = Theme.of(context).colorScheme; - if (state.isChecking || state.isBusy) { - return SizedBox( - width: 36, - height: 36, - child: Center( - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - value: state.progress, - ), - ), - ), - ); - } - if (state.isReady) { - return IconButton( - tooltip: 'Manage', - iconSize: 18, - onPressed: onManage, - icon: Icon(Icons.tune_rounded, color: colorScheme.onSurfaceVariant), - ); - } - return Icon( - Icons.chevron_right_rounded, - color: colorScheme.onSurfaceVariant, - ); - } - - void _onTap(LocalShellState state) { - if (state.isReady) { - onOpenSession(); - } else if (!state.isChecking && !state.isBusy) { - onManage(); - } - } - - String _statusLine(LocalShellState state) { - return switch (state.stage) { - LocalShellStage.ready => - 'Arch Linux · ${_formatBytes(state.diskUsageBytes)}', - LocalShellStage.checking => 'Arch Linux · checking…', - LocalShellStage.notInstalled => 'Arch Linux · tap to install (~142 MB)', - LocalShellStage.downloading => - 'Downloading… ${((state.progress ?? 0) * 100).toStringAsFixed(0)}%', - LocalShellStage.extracting => 'Unpacking…', - LocalShellStage.configuring => 'Configuring…', - LocalShellStage.failed => - state.error?.message ?? 'Setup failed - tap to retry', - LocalShellStage.unsupported => 'Not available on this device', - }; - } -} - -class _Avatar extends StatelessWidget { - const _Avatar({required this.ready}); - - final bool ready; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final accent = colorScheme.primary; - return Container( - width: 36, - height: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: ready - ? [accent, colorScheme.secondary] - : [ - colorScheme.surfaceContainerHigh, - colorScheme.surfaceContainerHigh, - ], - ), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: ready - ? accent.withValues(alpha: 0.4) - : colorScheme.outlineVariant, - ), - ), - child: Icon( - Icons.terminal_rounded, - size: 18, - color: ready ? colorScheme.onPrimary : colorScheme.onSurfaceVariant, - ), - ); - } -} - -String _formatBytes(int? bytes) { - if (bytes == null || bytes <= 0) return 'ready'; - const units = ['B', 'KB', 'MB', 'GB']; - var value = bytes.toDouble(); - var unit = 0; - while (value >= 1024 && unit < units.length - 1) { - value /= 1024; - unit++; - } - final fixed = unit == 0 ? value.toStringAsFixed(0) : value.toStringAsFixed(1); - return '$fixed ${units[unit]}'; -} diff --git a/lib/features/local_shell/presentation/widgets/local_shell_section.dart b/lib/features/local_shell/presentation/widgets/local_shell_section.dart new file mode 100644 index 0000000..c624d49 --- /dev/null +++ b/lib/features/local_shell/presentation/widgets/local_shell_section.dart @@ -0,0 +1,392 @@ +import 'package:conduit/features/local_shell/domain/local_shell_instance.dart'; +import 'package:conduit/features/local_shell/domain/local_shell_state.dart'; +import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; +import 'package:conduit/features/local_shell/presentation/local_shell_setup_page.dart'; +import 'package:flutter/material.dart'; + +class LocalShellSection extends StatelessWidget { + const LocalShellSection({ + required this.controller, + required this.activeInstanceIds, + required this.onAdd, + required this.onOpenInstance, + required this.onManageInstance, + super.key, + }); + + final LocalShellController controller; + final Set activeInstanceIds; + final VoidCallback onAdd; + final Future Function(LocalShellInstance instance) onOpenInstance; + final void Function(LocalShellInstance instance) onManageInstance; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + if (controller.isUnsupported) return const SizedBox.shrink(); + final instances = controller.instances; + return Padding( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionHeader( + showAdd: !controller.isChecking && instances.isNotEmpty, + onAdd: onAdd, + ), + const SizedBox(height: 10), + if (controller.isChecking) + const _CheckingCard() + else if (instances.isEmpty) + _SetupPromptCard(onTap: onAdd) + else + for (final instance in instances) ...[ + _InstanceCard( + instance: instance, + distroName: + controller.distroById(instance.distroId)?.name ?? + instance.distroId, + state: controller.stateFor(instance.id), + active: activeInstanceIds.contains(instance.id), + onOpen: () => onOpenInstance(instance), + onManage: () => onManageInstance(instance), + ), + if (instance != instances.last) const SizedBox(height: 8), + ], + ], + ), + ); + }, + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.showAdd, required this.onAdd}); + + final bool showAdd; + final VoidCallback onAdd; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Device', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 3), + Text( + 'Run commands locally, no server required.', + style: theme.textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + height: 1.25, + ), + ), + ], + ), + ), + if (showAdd) + IconButton( + tooltip: 'New local shell', + visualDensity: VisualDensity.compact, + onPressed: onAdd, + icon: Icon( + Icons.add_circle_outline_rounded, + color: colorScheme.primary, + ), + ), + ], + ); + } +} + +class _CheckingCard extends StatelessWidget { + const _CheckingCard(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return _CardShell( + highlighted: false, + child: Row( + children: [ + const _Avatar(ready: false), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Checking local shells…', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ), + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 9), + ], + ), + ); + } +} + +class _SetupPromptCard extends StatelessWidget { + const _SetupPromptCard({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: _CardShell( + highlighted: false, + child: Row( + children: [ + const _Avatar(ready: false), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Set up a local shell', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + const SizedBox(height: 2), + Text( + 'Arch, Debian, Ubuntu, Alpine and more', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: 12, + height: 1.25, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right_rounded, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ); + } +} + +class _InstanceCard extends StatelessWidget { + const _InstanceCard({ + required this.instance, + required this.distroName, + required this.state, + required this.active, + required this.onOpen, + required this.onManage, + }); + + final LocalShellInstance instance; + final String distroName; + final LocalShellState state; + final bool active; + final Future Function() onOpen; + final VoidCallback onManage; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final ready = state.isReady; + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () { + if (ready) { + onOpen(); + } else if (!state.isBusy) { + onManage(); + } + }, + child: _CardShell( + highlighted: ready && active, + child: Row( + children: [ + _Avatar(ready: ready), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + instance.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w800, + height: 1.1, + ), + ), + const SizedBox(height: 2), + Text( + _statusLine(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: state.stage == LocalShellStage.failed + ? colorScheme.error + : colorScheme.onSurfaceVariant, + fontSize: 12, + height: 1.25, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + _trailing(context), + ], + ), + ), + ), + ); + } + + Widget _trailing(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + if (state.isBusy) { + return SizedBox( + width: 36, + height: 36, + child: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + value: state.progress, + ), + ), + ), + ); + } + return IconButton( + tooltip: 'Manage', + iconSize: 18, + onPressed: onManage, + icon: Icon(Icons.tune_rounded, color: colorScheme.onSurfaceVariant), + ); + } + + String _statusLine() { + return switch (state.stage) { + LocalShellStage.ready => + '$distroName · ${formatLocalShellBytes(state.diskUsageBytes)}', + LocalShellStage.checking => '$distroName · checking…', + LocalShellStage.notInstalled => + '$distroName · setup incomplete, tap to resume', + LocalShellStage.downloading => + 'Downloading… ${((state.progress ?? 0) * 100).toStringAsFixed(0)}%', + LocalShellStage.extracting => 'Unpacking…', + LocalShellStage.configuring => 'Configuring…', + LocalShellStage.failed => + state.error?.message ?? 'Setup failed - tap to retry', + LocalShellStage.unsupported => 'Not available on this device', + }; + } +} + +class _CardShell extends StatelessWidget { + const _CardShell({required this.highlighted, required this.child}); + + final bool highlighted; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: highlighted + ? colorScheme.primary.withValues(alpha: 0.55) + : colorScheme.outlineVariant, + ), + ), + padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), + child: child, + ); + } +} + +class _Avatar extends StatelessWidget { + const _Avatar({required this.ready}); + + final bool ready; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final accent = colorScheme.primary; + return Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: ready + ? [accent, colorScheme.secondary] + : [ + colorScheme.surfaceContainerHigh, + colorScheme.surfaceContainerHigh, + ], + ), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: ready + ? accent.withValues(alpha: 0.4) + : colorScheme.outlineVariant, + ), + ), + child: Icon( + Icons.terminal_rounded, + size: 18, + color: ready ? colorScheme.onPrimary : colorScheme.onSurfaceVariant, + ), + ); + } +} diff --git a/lib/features/terminal/presentation/terminal_page.dart b/lib/features/terminal/presentation/terminal_page.dart index d5f0d43..5a0912f 100644 --- a/lib/features/terminal/presentation/terminal_page.dart +++ b/lib/features/terminal/presentation/terminal_page.dart @@ -18,6 +18,7 @@ import 'package:conduit/features/terminal/presentation/widgets/terminal_surface. import 'package:conduit_vt/conduit_vt.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; class TerminalPage extends StatefulWidget { const TerminalPage({ @@ -49,6 +50,7 @@ class _TerminalPageState extends State { @override void initState() { super.initState(); + unawaited(WakelockPlus.enable()); SecurityKeyInteraction.instance.registerPinPrompt(_promptSecurityKeyPin); SecurityKeyInteraction.instance.registerSelectionPrompt( _promptSecurityKeySelection, @@ -62,6 +64,7 @@ class _TerminalPageState extends State { @override void dispose() { + unawaited(WakelockPlus.disable()); _setSystemUiFullscreen(false); SecurityKeyInteraction.instance.unregisterPinPrompt(_promptSecurityKeyPin); SecurityKeyInteraction.instance.unregisterSelectionPrompt( @@ -194,6 +197,8 @@ class _TerminalPageState extends State { }, predictiveEchoEnabled: session.host.predictiveEchoEnabled, + terminalMouseInput: + widget.themeController.terminalMouseInput, focusNode: session == activeSession ? _focusNode : null, diff --git a/lib/features/terminal/presentation/terminal_session_controller.dart b/lib/features/terminal/presentation/terminal_session_controller.dart index d9ebcd8..7cc67cf 100644 --- a/lib/features/terminal/presentation/terminal_session_controller.dart +++ b/lib/features/terminal/presentation/terminal_session_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:conduit/core/app_failure.dart'; +import 'package:conduit/core/theme/terminal_appearance.dart'; import 'package:conduit/features/hosts/domain/saved_host.dart'; import 'package:conduit/features/terminal/domain/network_connectivity.dart'; import 'package:conduit/features/terminal/domain/predictive_echo.dart'; @@ -29,9 +30,11 @@ class TerminalSessionController extends ChangeNotifier { required this.repository, this.connectivity, bool predictiveEchoEnabled = false, + TerminalEnterSequence enterSequence = TerminalEnterSequence.cr, }) : keyboard = TerminalKeyboardController(defaultInputHandler), terminal = Terminal(maxLines: 10000) { _predictiveEchoEnabled = predictiveEchoEnabled; + _enterSequence = enterSequence; _configureTerminal(); } @@ -60,6 +63,7 @@ class TerminalSessionController extends ChangeNotifier { bool _disconnecting = false; bool _disposed = false; bool _predictiveEchoEnabled = false; + TerminalEnterSequence _enterSequence = TerminalEnterSequence.cr; int _connectionGeneration = 0; int? _lastIosEnterOutputMs; @@ -72,6 +76,7 @@ class TerminalSessionController extends ChangeNotifier { String get title => host.name; bool get isConnected => _status == TerminalConnectionStatus.connected; bool get predictiveEchoEnabled => _predictiveEchoEnabled; + TerminalEnterSequence get enterSequence => _enterSequence; Listenable get terminalPaintListenable => _terminalPaintNotifier; List get overlays { @@ -103,6 +108,14 @@ class TerminalSessionController extends ChangeNotifier { notifyListeners(); } + set enterSequence(TerminalEnterSequence sequence) { + if (_enterSequence == sequence) { + return; + } + _enterSequence = sequence; + notifyListeners(); + } + bool get shouldConnect => !_disconnecting && (_status == TerminalConnectionStatus.idle || @@ -297,7 +310,9 @@ class TerminalSessionController extends ChangeNotifier { if (snippet == null) { return; } - final text = snippet.submit ? '${snippet.text}\r' : snippet.text; + final text = snippet.submit + ? '${snippet.text}${_enterSequence.value}' + : snippet.text; if (text.isEmpty) { return; } @@ -370,7 +385,7 @@ class TerminalSessionController extends ChangeNotifier { if (startDirectory.isNotEmpty) { command.write(' -c ${_shellQuote(startDirectory)}'); } - command.write('\r'); + command.write(_enterSequence.value); return command.toString(); } @@ -394,7 +409,8 @@ class TerminalSessionController extends ChangeNotifier { } void _sendTerminalOutput(String data) { - if (_shouldSuppressDuplicateIosEnter(data)) { + final normalized = _normalizeEnterOutput(data); + if (_shouldSuppressDuplicateIosEnter(normalized)) { return; } @@ -403,7 +419,7 @@ class TerminalSessionController extends ChangeNotifier { return; } - final bytes = utf8.encode(data); + final bytes = utf8.encode(normalized); if (_predictiveEchoEnabled && session is PredictiveTerminalSession) { final predictiveSession = session as PredictiveTerminalSession; try { @@ -411,7 +427,7 @@ class TerminalSessionController extends ChangeNotifier { _predictiveEcho ..updateSrtt(predictiveSession.smoothedRtt) ..recordInput( - data, + normalized, inputNum: inputNum, cursorRow: terminal.absoluteCursorRow, cursorColumn: terminal.cursorColumn, @@ -448,6 +464,10 @@ class TerminalSessionController extends ChangeNotifier { return data == '\r' || data == '\n' || data == '\r\n'; } + String _normalizeEnterOutput(String data) { + return _isEnterOutput(data) ? _enterSequence.value : data; + } + void _writeTerminalOutput(String data) { terminal.write(data); if (_predictiveEcho.hasPredictions) { diff --git a/lib/features/terminal/presentation/terminal_workspace_controller.dart b/lib/features/terminal/presentation/terminal_workspace_controller.dart index 02b6fb0..db4b4f7 100644 --- a/lib/features/terminal/presentation/terminal_workspace_controller.dart +++ b/lib/features/terminal/presentation/terminal_workspace_controller.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:conduit/core/theme/terminal_appearance.dart'; import 'package:conduit/features/hosts/domain/saved_host.dart'; import 'package:conduit/features/terminal/domain/network_connectivity.dart'; import 'package:conduit/features/terminal/domain/ssh_terminal_repository.dart'; @@ -14,6 +15,7 @@ class TerminalWorkspaceController extends ChangeNotifier { final List _sessions = []; int _activeIndex = 0; + TerminalEnterSequence _enterSequence = TerminalEnterSequence.cr; List get sessions => List.unmodifiable(_sessions); @@ -36,6 +38,13 @@ class TerminalWorkspaceController extends ChangeNotifier { bool get hasLiveSessions => liveSessionCount > 0; + void setEnterSequence(TerminalEnterSequence sequence) { + _enterSequence = sequence; + for (final session in _sessions) { + session.enterSequence = sequence; + } + } + TerminalSessionController open(SavedHost host) { final existingIndex = _sessions.indexWhere( (session) => session.host.id == host.id, @@ -51,6 +60,7 @@ class TerminalWorkspaceController extends ChangeNotifier { repository: _repository, connectivity: _connectivity, predictiveEchoEnabled: host.predictiveEchoEnabled, + enterSequence: _enterSequence, ); session.addListener(notifyListeners); _sessions.add(session); diff --git a/lib/features/terminal/presentation/widgets/terminal_surface.dart b/lib/features/terminal/presentation/widgets/terminal_surface.dart index a2571fa..712e92d 100644 --- a/lib/features/terminal/presentation/widgets/terminal_surface.dart +++ b/lib/features/terminal/presentation/widgets/terminal_surface.dart @@ -12,6 +12,7 @@ class TerminalSurface extends StatefulWidget { required this.fontSize, required this.onFontSizeChanged, required this.predictiveEchoEnabled, + required this.terminalMouseInput, required this.focusNode, required this.tmuxScrollMode, required this.onExitTmuxScrollMode, @@ -25,6 +26,7 @@ class TerminalSurface extends StatefulWidget { final double fontSize; final ValueChanged onFontSizeChanged; final bool predictiveEchoEnabled; + final bool terminalMouseInput; final FocusNode? focusNode; final bool tmuxScrollMode; final VoidCallback onExitTmuxScrollMode; @@ -38,10 +40,20 @@ class _TerminalSurfaceState extends State { double? _pinchStartDistance; double? _pinchStartFontSize; double _tmuxScrollDelta = 0; + late final TerminalController _terminalController; + + static PointerInputs _pointerInputsFor(bool terminalMouseInput) { + return terminalMouseInput + ? const PointerInputs({PointerInput.tap}) + : const PointerInputs.none(); + } @override void initState() { super.initState(); + _terminalController = TerminalController( + pointerInputs: _pointerInputsFor(widget.terminalMouseInput), + ); widget.session.predictiveEchoEnabled = widget.predictiveEchoEnabled; WidgetsBinding.instance.addPostFrameCallback((_) => _connectIfNeeded()); } @@ -53,11 +65,22 @@ class _TerminalSurfaceState extends State { oldWidget.session != widget.session) { widget.session.predictiveEchoEnabled = widget.predictiveEchoEnabled; } + if (oldWidget.terminalMouseInput != widget.terminalMouseInput) { + _terminalController.setPointerInputs( + _pointerInputsFor(widget.terminalMouseInput), + ); + } if (oldWidget.session != widget.session) { WidgetsBinding.instance.addPostFrameCallback((_) => _connectIfNeeded()); } } + @override + void dispose() { + _terminalController.dispose(); + super.dispose(); + } + Future _connectIfNeeded() async { if (!mounted || !widget.session.shouldConnect) return; await widget.session.connect(); @@ -147,6 +170,7 @@ class _TerminalSurfaceState extends State { final overlays = widget.session.overlays; return TerminalView( widget.session.terminal, + controller: _terminalController, focusNode: widget.focusNode, autofocus: widget.focusNode != null, deleteDetection: true, diff --git a/lib/main.dart b/lib/main.dart index 0b61dde..edec549 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -56,7 +56,7 @@ void main() { ssh: DartSshTerminalRepository(hostKeyVerifier), mosh: MoshTerminalRepository(hostKeyVerifier), local: LocalTerminalRepository( - resolvePaths: localShellController.requirePaths, + resolveLaunch: localShellController.requireLaunch, ), ); final workspaceController = TerminalWorkspaceController( @@ -134,6 +134,14 @@ class _ConduitAppState extends State with WidgetsBindingObserver { super.initState(); WidgetsBinding.instance.addObserver(this); widget.workspaceController.addListener(_syncBackgroundKeepalive); + widget.themeController.addListener(_syncTerminalPreferences); + _syncTerminalPreferences(); + } + + void _syncTerminalPreferences() { + widget.workspaceController.setEnterSequence( + widget.themeController.terminalEnterSequence, + ); } @override @@ -191,6 +199,7 @@ class _ConduitAppState extends State with WidgetsBindingObserver { void dispose() { WidgetsBinding.instance.removeObserver(this); widget.workspaceController.removeListener(_syncBackgroundKeepalive); + widget.themeController.removeListener(_syncTerminalPreferences); unawaited(_backgroundKeepalive.stop()); super.dispose(); } diff --git a/pubspec.lock b/pubspec.lock index 24983ff..dd7c52a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -101,8 +101,8 @@ packages: dependency: "direct main" description: path: "." - ref: "57222273f969892c6b45b0fcfdbe65a0f54743a9" - resolved-ref: "57222273f969892c6b45b0fcfdbe65a0f54743a9" + ref: b486b894ea12b18b2ad76339ccd8c6ae3c12416f + resolved-ref: b486b894ea12b18b2ad76339ccd8c6ae3c12416f url: "https://github.com/gwitko/conduit_vt.git" source: git version: "0.1.1" @@ -554,6 +554,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: @@ -775,6 +791,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.2.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + url: "https://pub.dev" + source: hosted + version: "1.5.2" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b + url: "https://pub.dev" + source: hosted + version: "1.5.1" web: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d4ac85f..ea8c5c3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: conduit description: "A mobile terminal workspace for SSH, Mosh, SFTP, and local shells." publish_to: 'none' -version: 1.4.13+38 +version: 1.4.14+39 environment: sdk: ^3.12.0 @@ -22,7 +22,7 @@ dependencies: conduit_vt: git: url: https://github.com/gwitko/conduit_vt.git - ref: 57222273f969892c6b45b0fcfdbe65a0f54743a9 + ref: b486b894ea12b18b2ad76339ccd8c6ae3c12416f dart_mosh: ^0.0.4 flutter_pty: ^0.4.2 ffi: ^2.2.0 @@ -33,6 +33,7 @@ dependencies: local_auth: ^3.0.1 pinenacl: ^0.6.0 uuid: ^4.5.3 + wakelock_plus: ^1.2.10 file_picker: ^11.0.2 fido2: ^1.1.0 flutter_nfc_kit: ^3.6.2 diff --git a/test/core/theme_preferences_repository_test.dart b/test/core/theme_preferences_repository_test.dart index 75e4395..e43a1da 100644 --- a/test/core/theme_preferences_repository_test.dart +++ b/test/core/theme_preferences_repository_test.dart @@ -165,6 +165,47 @@ void main() { expect(preferences.showLocalShell, isFalse); }); + test( + 'defaults terminal mouse input off and persists when enabled', + () async { + final storage = InMemorySecureStorage(); + final repository = ThemePreferencesRepository(storage); + + final defaults = await repository.load(); + expect(defaults.terminalMouseInput, isFalse); + + await repository.save( + const ThemePreferences( + themeMode: ThemeMode.dark, + palette: AppPalette.synthwave, + terminalMouseInput: true, + ), + ); + + final preferences = await repository.load(); + expect(preferences.terminalMouseInput, isTrue); + }, + ); + + test('defaults enter sequence to CR and persists changes', () async { + final storage = InMemorySecureStorage(); + final repository = ThemePreferencesRepository(storage); + + final defaults = await repository.load(); + expect(defaults.terminalEnterSequence, TerminalEnterSequence.cr); + + await repository.save( + const ThemePreferences( + themeMode: ThemeMode.dark, + palette: AppPalette.synthwave, + terminalEnterSequence: TerminalEnterSequence.crlf, + ), + ); + + final preferences = await repository.load(); + expect(preferences.terminalEnterSequence, TerminalEnterSequence.crlf); + }); + test('persists and loads global snippets', () async { final storage = InMemorySecureStorage(); final repository = ThemePreferencesRepository(storage); diff --git a/test/core/theme_sheet_test.dart b/test/core/theme_sheet_test.dart index 8b0e8ae..d71e1e7 100644 --- a/test/core/theme_sheet_test.dart +++ b/test/core/theme_sheet_test.dart @@ -50,6 +50,89 @@ void main() { expect(controller.showLocalShell, isFalse); }); + testWidgets('appearance sheet toggles terminal mouse input', (tester) async { + final controller = ThemeController(InMemoryThemePreferences()); + await controller.load(); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: FilledButton( + onPressed: () { + showThemeSheet(context: context, controller: controller); + }, + child: const Text('Appearance'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Appearance')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Send mouse taps'), + 120, + scrollable: find.byType(Scrollable).last, + ); + await tester.pumpAndSettle(); + + expect(find.text('Send mouse taps'), findsOneWidget); + expect(controller.terminalMouseInput, isFalse); + + await tester.tap(find.text('Send mouse taps')); + await tester.pumpAndSettle(); + + expect(controller.terminalMouseInput, isTrue); + }); + + testWidgets('appearance sheet changes terminal enter sequence', ( + tester, + ) async { + final controller = ThemeController(InMemoryThemePreferences()); + await controller.load(); + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: FilledButton( + onPressed: () { + showThemeSheet(context: context, controller: controller); + }, + child: const Text('Appearance'), + ), + ), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Appearance')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Enter sends'), + 120, + scrollable: find.byType(Scrollable).last, + ); + await tester.pumpAndSettle(); + + expect(controller.terminalEnterSequence, TerminalEnterSequence.cr); + + await tester.tap(find.text('CRLF')); + await tester.pumpAndSettle(); + + expect(controller.terminalEnterSequence, TerminalEnterSequence.crlf); + }); + testWidgets('key row editor adds and saves a custom text key', ( tester, ) async { diff --git a/test/features/local_shell/default_manifest_test.dart b/test/features/local_shell/default_manifest_test.dart deleted file mode 100644 index 2c1058a..0000000 --- a/test/features/local_shell/default_manifest_test.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:conduit/features/local_shell/local_shell_config.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('defaultRootfsManifest', () { - final manifest = defaultRootfsManifest(); - - test('points at a verified https archive', () { - expect(manifest.archiveUrl.scheme, 'https'); - expect(manifest.archiveUrl.host, 'github.com'); - expect(manifest.archiveUrl.path, contains('.tar.xz')); - }); - - test('carries the verified checksum and size', () { - expect(manifest.sha256, hasLength(64)); - expect( - manifest.sha256, - 'b7e4cfb1414a281f90bfd39a503f72f38e03c31b356927972f797988fb48b5b1', - ); - expect(manifest.downloadSizeBytes, 149200240); - }); - - test('targets the Arch Linux ARM keyring and mirror', () { - expect(manifest.keyringName, 'archlinuxarm'); - expect(manifest.pacmanMirror, contains('archlinuxarm.org')); - }); - }); -} diff --git a/test/features/local_shell/distro_catalog_test.dart b/test/features/local_shell/distro_catalog_test.dart new file mode 100644 index 0000000..c3b94de --- /dev/null +++ b/test/features/local_shell/distro_catalog_test.dart @@ -0,0 +1,90 @@ +import 'package:conduit/features/local_shell/local_shell_config.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('defaultLocalShellDistros', () { + final catalog = defaultLocalShellDistros(); + + test('has unique ids and names', () { + final ids = catalog.map((distro) => distro.id).toSet(); + final names = catalog.map((distro) => distro.name).toSet(); + expect(ids.length, catalog.length); + expect(names.length, catalog.length); + }); + + test('keeps Arch Linux first under its shipped install directory id', () { + expect(catalog.first.id, defaultLocalShellDistroId); + expect(catalog.first.id, 'archlinux'); + expect(catalog.first.name, 'Arch Linux'); + }); + + test('every entry points at a verified https aarch64 archive', () { + for (final distro in catalog) { + expect(distro.manifest.archiveUrl.scheme, 'https', reason: distro.id); + expect( + distro.manifest.archiveUrl.path, + contains('aarch64'), + reason: distro.id, + ); + expect( + distro.manifest.archiveUrl.path, + endsWith('.tar.xz'), + reason: distro.id, + ); + expect( + RegExp(r'^[0-9a-f]{64}$').hasMatch(distro.manifest.sha256), + isTrue, + reason: distro.id, + ); + expect( + distro.manifest.downloadSizeBytes, + greaterThan(1024 * 1024), + reason: distro.id, + ); + expect(distro.manifest.version, isNotEmpty, reason: distro.id); + } + }); + + test('every entry carries an update command and login shell', () { + for (final distro in catalog) { + expect(distro.updateCommand, isNotEmpty, reason: distro.id); + expect(distro.loginCommand, isNotEmpty, reason: distro.id); + expect(distro.loginCommand.first, startsWith('/bin/')); + } + }); + + test('alpine logs in without bash', () { + final alpine = catalog.singleWhere((distro) => distro.id == 'alpine'); + expect(alpine.loginCommand.first, '/bin/sh'); + }); + + test('pacman distros initialise their keyring during setup', () { + for (final id in ['archlinux', 'manjaro']) { + final distro = catalog.singleWhere((entry) => entry.id == id); + final script = distro.setupCommands.join('\n'); + final entropyIndex = script.indexOf('/dev/urandom'); + final initIndex = script.indexOf('pacman-key --init'); + final populateIndex = script.indexOf('pacman-key --populate'); + expect(entropyIndex, greaterThanOrEqualTo(0), reason: id); + expect(initIndex, greaterThan(entropyIndex), reason: id); + expect(populateIndex, greaterThan(initIndex), reason: id); + } + }); + + test('arch pins the Arch Linux ARM mirror', () { + final arch = catalog.first; + expect(arch.setupCommands.join('\n'), contains('archlinuxarm.org')); + }); + + test('debian family regenerates locales non-interactively', () { + for (final id in ['debian', 'ubuntu']) { + final distro = catalog.singleWhere((entry) => entry.id == id); + expect( + distro.setupCommands.join('\n'), + contains('DEBIAN_FRONTEND=noninteractive dpkg-reconfigure locales'), + reason: id, + ); + } + }); + }); +} diff --git a/test/features/local_shell/first_boot_script_test.dart b/test/features/local_shell/first_boot_script_test.dart index 262d9f4..95e5319 100644 --- a/test/features/local_shell/first_boot_script_test.dart +++ b/test/features/local_shell/first_boot_script_test.dart @@ -4,44 +4,42 @@ import 'package:flutter_test/flutter_test.dart'; void main() { const generator = FirstBootScript(); const config = FirstBootConfig( - pacmanMirror: r'http://mirror.archlinuxarm.org/$arch/$repo', - keyringName: 'archlinuxarm', + distroName: 'Debian', + updateCommand: 'apt update && apt upgrade', + setupCommands: ['echo first-setup-step', 'echo second-setup-step'], doneMarkerPath: '/var/lib/.conduit-firstboot-done', ); group('FirstBootScript', () { final script = generator.generate(config); + test('is POSIX sh, not bash', () { + expect(script, startsWith('#!/bin/sh\n')); + expect(script, contains('set -eu\n')); + expect(script, isNot(contains('pipefail'))); + }); + test('configures DNS for every nameserver', () { expect(script, contains('> /etc/resolv.conf')); expect(script, contains('nameserver 1.1.1.1')); expect(script, contains('nameserver 8.8.8.8')); }); - test('pins the pacman mirror', () { - expect( - script, - contains( - r"echo 'Server = http://mirror.archlinuxarm.org/$arch/$repo' > " - '/etc/pacman.d/mirrorlist', - ), - ); - }); - - test('generates locales', () { - expect(script, contains('locale-gen')); - expect(script, contains('/etc/locale.gen')); + test('runs the distro setup commands in order', () { + final firstIndex = script.indexOf('echo first-setup-step'); + final secondIndex = script.indexOf('echo second-setup-step'); + expect(firstIndex, greaterThanOrEqualTo(0)); + expect(secondIndex, greaterThan(firstIndex)); }); - test('seeds entropy then initialises the keyring', () { - final entropyIndex = script.indexOf('/dev/urandom'); - final initIndex = script.indexOf('pacman-key --init'); - final populateIndex = script.indexOf( - 'pacman-key --populate archlinuxarm', + test('omits the setup section when a distro needs none', () { + const bare = FirstBootConfig( + distroName: 'Alpine Linux', + updateCommand: 'apk update && apk upgrade', + setupCommands: [], + doneMarkerPath: '/var/lib/.conduit-firstboot-done', ); - expect(entropyIndex, greaterThanOrEqualTo(0)); - expect(initIndex, greaterThan(entropyIndex)); - expect(populateIndex, greaterThan(initIndex)); + expect(generator.generate(bare), isNot(contains('distro setup'))); }); test('is idempotent via a completion marker', () { @@ -49,9 +47,10 @@ void main() { expect(script, contains('touch "/var/lib/.conduit-firstboot-done"')); }); - test('installs a one-time welcome hint pointing at pacman -Syu', () { + test('installs a one-time welcome naming the distro and its updater', () { expect(script, contains('/etc/profile.d/conduit-welcome.sh')); - expect(script, contains('pacman -Syu')); + expect(script, contains('Debian - running locally via Conduit.')); + expect(script, contains('apt update && apt upgrade')); }); }); } diff --git a/test/features/local_shell/local_shell_card_test.dart b/test/features/local_shell/local_shell_card_test.dart deleted file mode 100644 index 6e72789..0000000 --- a/test/features/local_shell/local_shell_card_test.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:conduit/features/local_shell/domain/local_shell_state.dart'; -import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; -import 'package:conduit/features/local_shell/presentation/widgets/local_shell_card.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -Widget wrap(LocalShellController controller, {required VoidCallback onManage}) { - return MaterialApp( - home: Scaffold( - body: LocalShellCard( - controller: controller, - active: false, - onOpenSession: () async {}, - onManage: onManage, - ), - ), - ); -} - -void main() { - testWidgets('shows a checking state before probing completes', ( - tester, - ) async { - final controller = LocalShellController(); - var managed = false; - await tester.pumpWidget(wrap(controller, onManage: () => managed = true)); - - expect(find.text('Local shell'), findsOneWidget); - expect(find.textContaining('checking'), findsOneWidget); - expect(find.textContaining('tap to install'), findsNothing); - - await tester.tap(find.text('Local shell')); - await tester.pump(); - expect(managed, isFalse); - }); - - testWidgets('shows an install affordance when not installed', (tester) async { - final controller = _FakeLocalShellController(LocalShellState.notInstalled); - var managed = false; - await tester.pumpWidget(wrap(controller, onManage: () => managed = true)); - - expect(find.text('Local shell'), findsOneWidget); - expect(find.textContaining('tap to install'), findsOneWidget); - - await tester.tap(find.text('Local shell')); - await tester.pump(); - expect(managed, isTrue); - }); - - testWidgets('hides itself on unsupported devices', (tester) async { - final controller = LocalShellController(); - await controller.refresh(); - await tester.pumpWidget(wrap(controller, onManage: () {})); - - expect(find.text('Local shell'), findsNothing); - }); -} - -class _FakeLocalShellController extends LocalShellController { - _FakeLocalShellController(this._state); - - final LocalShellState _state; - - @override - LocalShellState get state => _state; -} diff --git a/test/features/local_shell/local_shell_controller_test.dart b/test/features/local_shell/local_shell_controller_test.dart index 593826b..41d0426 100644 --- a/test/features/local_shell/local_shell_controller_test.dart +++ b/test/features/local_shell/local_shell_controller_test.dart @@ -1,12 +1,14 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:conduit/features/local_shell/data/local_shell_platform.dart'; -import 'package:conduit/features/local_shell/data/rootfs_manifest_source.dart'; import 'package:conduit/features/local_shell/domain/local_shell_state.dart'; -import 'package:conduit/features/local_shell/domain/rootfs_manifest.dart'; import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:path/path.dart' as p; class FakeLocalShellPlatform extends LocalShellPlatform { FakeLocalShellPlatform(this.environment); @@ -21,15 +23,6 @@ class FakeLocalShellPlatform extends LocalShellPlatform { } } -class ThrowingManifestSource implements RootfsManifestSource { - const ThrowingManifestSource(); - - @override - Future fetch() { - throw const FormatException('test manifest failure'); - } -} - void main() { group('LocalShellController', () { late Directory tempDir; @@ -44,39 +37,208 @@ void main() { } }); + LocalShellEnvironment environment() => LocalShellEnvironment( + nativeLibraryDir: tempDir.path, + filesDir: tempDir.path, + sharedStorageFeatureEnabled: true, + sharedStorageDir: '/storage/emulated/0', + sharedStorageAccessGranted: true, + supportedAbis: const ['arm64-v8a'], + ); + + MockClient offlineClient() => MockClient((request) { + throw http.ClientException('offline', request.url); + }); + + Future seedLegacyInstall(String directoryName) async { + await File( + p.join( + tempDir.path, + directoryName, + 'rootfs', + 'var', + 'lib', + '.conduit-firstboot-done', + ), + ).create(recursive: true); + } + + test('recognises a pre-redesign arch install untouched', () async { + await seedLegacyInstall('archlinux'); + await File( + p.join(tempDir.path, 'archlinux', '.version'), + ).writeAsString('archlinux-aarch64-pd-v4.22.1'); + + final controller = LocalShellController( + platform: FakeLocalShellPlatform(environment), + ); + await controller.refresh(); + + final instance = controller.instances.single; + expect(instance.id, 'archlinux'); + expect(instance.distroId, 'archlinux'); + expect(instance.name, 'Arch Linux'); + final state = controller.stateFor('archlinux'); + expect(state.stage, LocalShellStage.ready); + expect(state.installedVersion, 'archlinux-aarch64-pd-v4.22.1'); + expect(controller.defaultInstance?.id, 'archlinux'); + }); + + test('discovers named instances from their metadata files', () async { + await seedLegacyInstall('debian-2'); + await File( + p.join(tempDir.path, 'debian-2', '.conduit-instance'), + ).writeAsString(jsonEncode({'distroId': 'debian', 'name': 'Work'})); + + final controller = LocalShellController( + platform: FakeLocalShellPlatform(environment), + ); + await controller.refresh(); + + final instance = controller.instances.single; + expect(instance.id, 'debian-2'); + expect(instance.distroId, 'debian'); + expect(instance.name, 'Work'); + expect(controller.stateFor('debian-2').stage, LocalShellStage.ready); + }); + + test('installNew waits for an in-flight probe', () async { + final probe = Completer(); + final platform = FakeLocalShellPlatform(() => probe.future); + final controller = LocalShellController( + platform: platform, + httpClient: offlineClient(), + ); + + unawaited(controller.refresh()); + final install = controller.installNew('archlinux'); + await Future.delayed(Duration.zero); + + probe.complete(environment()); + await install; + + expect(platform.loadCount, 1); + final instance = controller.instances.single; + expect(instance.id, 'archlinux'); + final state = controller.stateFor(instance.id); + expect(state.stage, LocalShellStage.failed); + expect(state.error?.kind, LocalShellErrorKind.network); + }); + test( - 'install waits for an in-flight probe before deciding support', + 'installNew mints a suffixed instance next to an existing one', () async { - final probe = Completer(); - final platform = FakeLocalShellPlatform(() => probe.future); - final controller = LocalShellController( - platform: platform, - manifestSource: const ThrowingManifestSource(), - ); + await seedLegacyInstall('debian'); - unawaited(controller.refresh()); - final install = controller.install(); - await Future.delayed(Duration.zero); - - probe.complete( - LocalShellEnvironment( - nativeLibraryDir: tempDir.path, - filesDir: tempDir.path, - sharedStorageFeatureEnabled: true, - sharedStorageDir: '/storage/emulated/0', - sharedStorageAccessGranted: true, - supportedAbis: const ['arm64-v8a'], - ), + final controller = LocalShellController( + platform: FakeLocalShellPlatform(environment), + httpClient: offlineClient(), ); - await install; + await controller.refresh(); + await controller.installNew('debian', name: 'Playground'); - expect(platform.loadCount, 1); - expect(controller.state.stage, LocalShellStage.failed); + expect(controller.instances, hasLength(2)); + final second = controller.instances.last; + expect(second.id, 'debian-2'); + expect(second.distroId, 'debian'); + expect(second.name, 'Playground'); + expect(controller.stateFor('debian-2').stage, LocalShellStage.failed); + expect(controller.stateFor('debian').stage, LocalShellStage.ready); expect( - controller.state.error?.kind, - isNot(LocalShellErrorKind.unsupportedDevice), + File( + p.join(tempDir.path, 'debian-2', '.conduit-instance'), + ).existsSync(), + isTrue, ); }, ); + + test('rename persists across rediscovery', () async { + await seedLegacyInstall('archlinux'); + + final controller = LocalShellController( + platform: FakeLocalShellPlatform(environment), + ); + await controller.refresh(); + await controller.rename('archlinux', 'My Arch'); + expect(controller.instances.single.name, 'My Arch'); + + final revived = LocalShellController( + platform: FakeLocalShellPlatform(environment), + ); + await revived.refresh(); + expect(revived.instances.single.name, 'My Arch'); + expect(revived.instances.single.distroId, 'archlinux'); + }); + + test('remove deletes the environment and forgets the instance', () async { + await seedLegacyInstall('archlinux'); + + final controller = LocalShellController( + platform: FakeLocalShellPlatform(environment), + ); + await controller.refresh(); + await controller.remove('archlinux'); + + expect(controller.instances, isEmpty); + expect(controller.state.stage, LocalShellStage.notInstalled); + }); + + test('defaultInstance follows the last opened instance', () async { + await seedLegacyInstall('archlinux'); + await seedLegacyInstall('debian'); + + final controller = LocalShellController( + platform: FakeLocalShellPlatform(environment), + ); + await controller.refresh(); + expect(controller.defaultInstance?.id, 'archlinux'); + + await controller.markOpened('debian'); + expect(controller.defaultInstance?.id, 'debian'); + + final revived = LocalShellController( + platform: FakeLocalShellPlatform(environment), + ); + await revived.refresh(); + expect(revived.defaultInstance?.id, 'debian'); + }); + + test('requireLaunch resolves instance and distro from host ids', () async { + await seedLegacyInstall('debian-2'); + await File( + p.join(tempDir.path, 'debian-2', '.conduit-instance'), + ).writeAsString(jsonEncode({'distroId': 'debian', 'name': 'Work'})); + + final controller = LocalShellController( + platform: FakeLocalShellPlatform(environment), + ); + await controller.refresh(); + + final launch = await controller.requireLaunch( + '${localShellHostIdFor('debian-2')}#12345', + ); + expect(launch.distro.id, 'debian'); + expect(launch.paths.installRoot, p.join(tempDir.path, 'debian-2')); + }); + + test('localHost mints numbered ids for additional sessions', () async { + await seedLegacyInstall('archlinux'); + final controller = LocalShellController( + platform: FakeLocalShellPlatform(environment), + ); + await controller.refresh(); + + final instance = controller.instances.single; + final first = controller.localHost(instance); + expect(first.id, localShellHostIdFor('archlinux')); + expect(first.name, 'Arch Linux'); + expect(first.isLocal, isTrue); + + final second = controller.localHost(instance, sessionNumber: 2); + expect(second.name, 'Arch Linux (2)'); + expect(second.id, isNot(first.id)); + expect(localShellInstanceIdFromHostId(second.id), 'archlinux'); + }); }); } diff --git a/test/features/local_shell/local_shell_instance_page_test.dart b/test/features/local_shell/local_shell_instance_page_test.dart new file mode 100644 index 0000000..6fe8533 --- /dev/null +++ b/test/features/local_shell/local_shell_instance_page_test.dart @@ -0,0 +1,119 @@ +import 'package:conduit/features/local_shell/domain/local_shell_instance.dart'; +import 'package:conduit/features/local_shell/domain/local_shell_state.dart'; +import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; +import 'package:conduit/features/local_shell/presentation/local_shell_instance_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeController extends LocalShellController { + _FakeController(this.events); + + final List events; + LocalShellInstance? instance = const LocalShellInstance( + id: 'archlinux', + distroId: 'archlinux', + name: 'Arch Linux', + ); + + @override + List get instances => + instance == null ? const [] : [instance!]; + + @override + LocalShellInstance? instanceById(String instanceId) => + instance?.id == instanceId ? instance : null; + + @override + LocalShellState stateFor(String instanceId) => const LocalShellState( + stage: LocalShellStage.ready, + installedVersion: 'test', + diskUsageBytes: 1024, + ); + + @override + Future refresh() async {} + + @override + Future remove(String instanceId) async { + events.add('remove:$instanceId'); + instance = null; + notifyListeners(); + } + + @override + Future rename(String instanceId, String name) async { + events.add('rename:$instanceId:${name.trim()}'); + instance = instance?.copyWith(name: name.trim()); + notifyListeners(); + } +} + +Future _pumpInstancePage( + WidgetTester tester, + _FakeController controller, + List events, +) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LocalShellInstancePage( + controller: controller, + instanceId: 'archlinux', + onOpenSession: (instance) async => + events.add('open:${instance.id}'), + onCloseSessions: (instanceId) async => + events.add('close:$instanceId'), + ), + ), + ), + child: const Text('go'), + ), + ), + ), + ); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('opens a new session', (tester) async { + final events = []; + final controller = _FakeController(events); + await _pumpInstancePage(tester, controller, events); + + await tester.tap(find.text('New session')); + expect(events, ['open:archlinux']); + }); + + testWidgets('closes sessions before removing, then pops', (tester) async { + final events = []; + final controller = _FakeController(events); + await _pumpInstancePage(tester, controller, events); + + await tester.tap(find.text('Remove')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Remove').last); + await tester.pumpAndSettle(); + + expect(events, ['close:archlinux', 'remove:archlinux']); + expect(find.text('New session'), findsNothing); + }); + + testWidgets('renames via the app bar action', (tester) async { + final events = []; + final controller = _FakeController(events); + await _pumpInstancePage(tester, controller, events); + + await tester.tap(find.byTooltip('Rename')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField), 'My Arch'); + await tester.tap(find.text('Rename').last); + await tester.pumpAndSettle(); + + expect(events, ['rename:archlinux:My Arch']); + expect(find.text('My Arch'), findsOneWidget); + }); +} diff --git a/test/features/local_shell/local_shell_instance_store_test.dart b/test/features/local_shell/local_shell_instance_store_test.dart new file mode 100644 index 0000000..e050a9e --- /dev/null +++ b/test/features/local_shell/local_shell_instance_store_test.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:conduit/features/local_shell/data/local_shell_instance_store.dart'; +import 'package:conduit/features/local_shell/local_shell_config.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +void main() { + group('LocalShellInstanceStore', () { + late Directory tempDir; + late LocalShellInstanceStore store; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp( + 'conduit_instance_store_test_', + ); + store = LocalShellInstanceStore( + dataDir: tempDir.path, + catalog: defaultLocalShellDistros(), + ); + }); + + tearDown(() async { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('ignores unrelated directories and files', () async { + await Directory(p.join(tempDir.path, 'cache')).create(); + await Directory(p.join(tempDir.path, 'flutter_assets')).create(); + await File(p.join(tempDir.path, 'archlinux')).create(); + + expect(await store.discover(), isEmpty); + }); + + test('legacy detection requires a rootfs directory', () async { + await Directory(p.join(tempDir.path, 'archlinux')).create(); + expect(await store.discover(), isEmpty); + + await Directory(p.join(tempDir.path, 'archlinux', 'rootfs')).create(); + final instance = (await store.discover()).single; + expect(instance.id, 'archlinux'); + expect(instance.distroId, 'archlinux'); + expect(instance.name, 'Arch Linux'); + }); + + test('metadata rides over the legacy fallback', () async { + await Directory( + p.join(tempDir.path, 'alpine', 'rootfs'), + ).create(recursive: true); + await File( + p.join(tempDir.path, 'alpine', LocalShellInstanceStore.metaFileName), + ).writeAsString(jsonEncode({'distroId': 'alpine', 'name': 'Tiny box'})); + + final instance = (await store.discover()).single; + expect(instance.name, 'Tiny box'); + }); + + test('skips metadata naming an unknown distro', () async { + await Directory(p.join(tempDir.path, 'mystery')).create(); + await File( + p.join(tempDir.path, 'mystery', LocalShellInstanceStore.metaFileName), + ).writeAsString(jsonEncode({'distroId': 'gentoo', 'name': 'Nope'})); + + expect(await store.discover(), isEmpty); + }); + + test('createInstance suffixes past occupied directories', () async { + final catalog = defaultLocalShellDistros(); + final debian = catalog.singleWhere((distro) => distro.id == 'debian'); + + final first = await store.createInstance(debian); + expect(first.id, 'debian'); + expect(first.name, 'Debian'); + + await Directory(p.join(tempDir.path, 'debian')).create(); + await Directory(p.join(tempDir.path, 'debian-2')).create(); + final third = await store.createInstance(debian); + expect(third.id, 'debian-3'); + expect(third.name, 'Debian 3'); + + final named = await store.createInstance(debian, name: ' Sandbox '); + expect(named.name, 'Sandbox'); + }); + }); +} diff --git a/test/features/local_shell/local_shell_page_test.dart b/test/features/local_shell/local_shell_page_test.dart deleted file mode 100644 index f96a7b9..0000000 --- a/test/features/local_shell/local_shell_page_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:conduit/features/local_shell/domain/local_shell_state.dart'; -import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; -import 'package:conduit/features/local_shell/presentation/local_shell_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -class FakeReadyLocalShellController extends LocalShellController { - FakeReadyLocalShellController(this.events); - - final List events; - LocalShellState _fakeState = const LocalShellState( - stage: LocalShellStage.ready, - installedVersion: 'test', - diskUsageBytes: 1024, - ); - - @override - LocalShellState get state => _fakeState; - - @override - Future refresh() async {} - - @override - Future reset() async { - events.add('reset'); - _fakeState = LocalShellState.notInstalled; - notifyListeners(); - } -} - -void main() { - group('LocalShellPage', () { - testWidgets('closes local sessions before removing the environment', ( - tester, - ) async { - final events = []; - final controller = FakeReadyLocalShellController(events); - - await tester.pumpWidget( - MaterialApp( - home: LocalShellPage( - controller: controller, - onOpenSession: () async {}, - onCloseSession: () async => events.add('close'), - ), - ), - ); - - await tester.tap(find.text('Remove local shell')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Remove').last); - await tester.pumpAndSettle(); - - expect(events, ['close', 'reset']); - expect(controller.state.stage, LocalShellStage.notInstalled); - }); - }); -} diff --git a/test/features/local_shell/local_shell_section_test.dart b/test/features/local_shell/local_shell_section_test.dart new file mode 100644 index 0000000..06cce17 --- /dev/null +++ b/test/features/local_shell/local_shell_section_test.dart @@ -0,0 +1,121 @@ +import 'package:conduit/features/local_shell/domain/local_shell_instance.dart'; +import 'package:conduit/features/local_shell/domain/local_shell_state.dart'; +import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; +import 'package:conduit/features/local_shell/presentation/widgets/local_shell_section.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeController extends LocalShellController { + _FakeController({this.fakeInstances = const [], this.readyIds = const {}}); + + final List fakeInstances; + final Set readyIds; + + @override + bool get isChecking => false; + + @override + List get instances => fakeInstances; + + @override + LocalShellInstance? instanceById(String instanceId) { + for (final instance in fakeInstances) { + if (instance.id == instanceId) return instance; + } + return null; + } + + @override + LocalShellState stateFor(String instanceId) => readyIds.contains(instanceId) + ? const LocalShellState( + stage: LocalShellStage.ready, + diskUsageBytes: 2048, + ) + : LocalShellState.notInstalled; + + @override + Future refresh() async {} +} + +Widget wrap( + LocalShellController controller, { + VoidCallback? onAdd, + Future Function(LocalShellInstance)? onOpen, + void Function(LocalShellInstance)? onManage, +}) { + return MaterialApp( + home: Scaffold( + body: LocalShellSection( + controller: controller, + activeInstanceIds: const {}, + onAdd: onAdd ?? () {}, + onOpenInstance: onOpen ?? (_) async {}, + onManageInstance: onManage ?? (_) {}, + ), + ), + ); +} + +void main() { + testWidgets('shows a checking placeholder before probing', (tester) async { + final controller = LocalShellController(); + await tester.pumpWidget(wrap(controller)); + + expect(find.text('Device'), findsOneWidget); + expect(find.textContaining('Checking'), findsOneWidget); + }); + + testWidgets('prompts setup when no shells exist', (tester) async { + var added = false; + final controller = _FakeController(); + await tester.pumpWidget(wrap(controller, onAdd: () => added = true)); + + expect(find.text('Set up a local shell'), findsOneWidget); + await tester.tap(find.text('Set up a local shell')); + expect(added, isTrue); + }); + + testWidgets('lists instances and routes open and manage taps', ( + tester, + ) async { + final opened = []; + final managed = []; + final controller = _FakeController( + fakeInstances: const [ + LocalShellInstance( + id: 'archlinux', + distroId: 'archlinux', + name: 'Arch Linux', + ), + LocalShellInstance(id: 'debian-2', distroId: 'debian', name: 'Work'), + ], + readyIds: const {'archlinux'}, + ); + await tester.pumpWidget( + wrap( + controller, + onOpen: (instance) async => opened.add(instance.id), + onManage: (instance) => managed.add(instance.id), + ), + ); + + expect(find.text('Arch Linux'), findsOneWidget); + expect(find.text('Work'), findsOneWidget); + expect(find.textContaining('setup incomplete'), findsOneWidget); + expect(find.byTooltip('New local shell'), findsOneWidget); + + await tester.tap(find.text('Arch Linux')); + expect(opened, ['archlinux']); + + await tester.tap(find.text('Work')); + expect(managed, ['debian-2']); + }); + + testWidgets('hides itself on unsupported devices', (tester) async { + final controller = LocalShellController(); + await controller.refresh(); + await tester.pumpWidget(wrap(controller)); + + expect(find.text('Device'), findsNothing); + }); +} diff --git a/test/features/local_shell/local_shell_setup_page_test.dart b/test/features/local_shell/local_shell_setup_page_test.dart new file mode 100644 index 0000000..4bf29a6 --- /dev/null +++ b/test/features/local_shell/local_shell_setup_page_test.dart @@ -0,0 +1,79 @@ +import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; +import 'package:conduit/features/local_shell/presentation/local_shell_setup_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late LocalShellSetupRequest? result; + + Future pumpSetupPage( + WidgetTester tester, + LocalShellController controller, + ) async { + result = null; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () async { + result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LocalShellSetupPage(controller: controller), + ), + ); + }, + child: const Text('go'), + ), + ), + ), + ); + await tester.tap(find.text('go')); + await tester.pumpAndSettle(); + } + + String nameFieldText(WidgetTester tester) => + tester.widget(find.byType(TextField)).controller?.text ?? ''; + + testWidgets('lists the catalog and prefills the name', (tester) async { + final controller = LocalShellController(); + await pumpSetupPage(tester, controller); + + for (final distro in controller.catalog) { + expect(find.text(distro.name), findsWidgets); + } + expect(nameFieldText(tester), 'Arch Linux'); + }); + + testWidgets('selecting a distro updates the suggested name', (tester) async { + await pumpSetupPage(tester, LocalShellController()); + + await tester.tap(find.text('Debian')); + await tester.pump(); + expect(nameFieldText(tester), 'Debian'); + }); + + testWidgets('a custom name survives switching distros', (tester) async { + await pumpSetupPage(tester, LocalShellController()); + + await tester.enterText(find.byType(TextField), 'Sandbox'); + await tester.tap(find.text('Ubuntu')); + await tester.pump(); + expect(nameFieldText(tester), 'Sandbox'); + }); + + testWidgets('submitting returns the chosen distro and name', (tester) async { + await pumpSetupPage(tester, LocalShellController()); + + await tester.ensureVisible(find.text('Alpine Linux')); + await tester.tap(find.text('Alpine Linux')); + await tester.pump(); + await tester.enterText(find.byType(TextField), 'Tiny box'); + await tester.ensureVisible(find.textContaining('Install ·')); + await tester.tap(find.textContaining('Install ·')); + await tester.pumpAndSettle(); + + expect(result, isNotNull); + expect(result!.distroId, 'alpine'); + expect(result!.name, 'Tiny box'); + }); +} diff --git a/test/features/local_shell/local_shell_state_machine_test.dart b/test/features/local_shell/local_shell_state_machine_test.dart index 60f8ba7..c3a0b8e 100644 --- a/test/features/local_shell/local_shell_state_machine_test.dart +++ b/test/features/local_shell/local_shell_state_machine_test.dart @@ -15,15 +15,19 @@ void main() { }); test('install request begins downloading at zero progress', () { - final next = machine.reduce(notInstalled, const InstallRequested()); + final next = machine.reduce( + notInstalled, + const InstallRequested(distroName: 'Arch Linux'), + ); expect(next.stage, LocalShellStage.downloading); expect(next.progress, 0); + expect(next.message, contains('Arch Linux')); }); test('download progress updates only while downloading', () { final downloading = machine.reduce( notInstalled, - const InstallRequested(), + const InstallRequested(distroName: 'Arch Linux'), ); final progressed = machine.reduce( downloading, @@ -41,7 +45,7 @@ void main() { test('progress is clamped to 0..1', () { final downloading = machine.reduce( notInstalled, - const InstallRequested(), + const InstallRequested(distroName: 'Arch Linux'), ); expect( machine.reduce(downloading, const DownloadProgressed(2)).progress, @@ -54,7 +58,10 @@ void main() { }); test('full install path reaches ready', () { - var state = machine.reduce(notInstalled, const InstallRequested()); + var state = machine.reduce( + notInstalled, + const InstallRequested(distroName: 'Arch Linux'), + ); state = machine.reduce(state, const DownloadFinished()); expect(state.stage, LocalShellStage.extracting); state = machine.reduce(state, const ExtractFinished()); @@ -83,7 +90,7 @@ void main() { test('failure carries the error and is installable again', () { final downloading = machine.reduce( notInstalled, - const InstallRequested(), + const InstallRequested(distroName: 'Arch Linux'), ); const error = LocalShellError(LocalShellErrorKind.network, 'offline'); final failed = machine.reduce(downloading, const InstallFailed(error)); @@ -92,22 +99,6 @@ void main() { expect(failed.canInstall, isTrue); }); - test('unsupported device is sticky against environment probes', () { - final unsupported = machine.reduce( - initial, - const DeviceUnsupported('no arm64'), - ); - expect(unsupported.stage, LocalShellStage.unsupported); - expect( - machine.reduce(unsupported, const EnvironmentMissing()), - unsupported, - ); - expect( - machine.reduce(unsupported, const EnvironmentReady(version: 'x')), - unsupported, - ); - }); - test('environment probes set ready / not-installed', () { final ready = machine.reduce( initial, diff --git a/test/features/local_shell/local_shell_store_test.dart b/test/features/local_shell/local_shell_store_test.dart index fe68e80..ae08275 100644 --- a/test/features/local_shell/local_shell_store_test.dart +++ b/test/features/local_shell/local_shell_store_test.dart @@ -21,6 +21,7 @@ void main() { test('diskUsageBytes skips unreadable rootfs directories', () async { final paths = LocalShellPaths( + instanceId: 'archlinux', nativeLibraryDir: tempDir.path, dataDir: tempDir.path, ); diff --git a/test/features/local_shell/local_terminal_repository_test.dart b/test/features/local_shell/local_terminal_repository_test.dart index 89e1393..03537be 100644 --- a/test/features/local_shell/local_terminal_repository_test.dart +++ b/test/features/local_shell/local_terminal_repository_test.dart @@ -4,8 +4,10 @@ import 'dart:typed_data'; import 'package:conduit/features/hosts/domain/saved_host.dart'; import 'package:conduit/features/local_shell/data/local_terminal_repository.dart'; +import 'package:conduit/features/local_shell/domain/local_shell_distro.dart'; import 'package:conduit/features/local_shell/domain/local_shell_paths.dart'; import 'package:conduit/features/local_shell/domain/pty_process.dart'; +import 'package:conduit/features/local_shell/local_shell_config.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:path/path.dart' as p; @@ -27,7 +29,9 @@ void main() { test('binds shared Android storage into the local shell', () async { late List capturedArguments; + final distro = defaultLocalShellDistros().first; final paths = LocalShellPaths( + instanceId: distro.id, nativeLibraryDir: p.join(tempDir.path, 'lib'), dataDir: p.join(tempDir.path, 'files'), sharedStorageFeatureEnabled: true, @@ -35,7 +39,8 @@ void main() { sharedStorageAccessGranted: true, ); final repository = LocalTerminalRepository( - resolvePaths: () async => paths, + resolveLaunch: (hostId) async => + LocalShellLaunch(distro: distro, paths: paths), processFactory: ({ required executable, @@ -50,7 +55,7 @@ void main() { ); await repository.connect( - SavedHost.localShell(id: 'local'), + SavedHost.localShell(id: 'local', name: distro.name), columns: 80, rows: 24, ); @@ -67,6 +72,45 @@ void main() { ]), ); }); + + test('launches the distro login shell with its prompt label', () async { + late List capturedArguments; + final distro = defaultLocalShellDistros().singleWhere( + (entry) => entry.id == 'alpine', + ); + final paths = LocalShellPaths( + instanceId: distro.id, + nativeLibraryDir: p.join(tempDir.path, 'lib'), + dataDir: p.join(tempDir.path, 'files'), + ); + final repository = LocalTerminalRepository( + resolveLaunch: (hostId) async => + LocalShellLaunch(distro: distro, paths: paths), + processFactory: + ({ + required executable, + required arguments, + required environment, + required rows, + required columns, + }) { + capturedArguments = arguments; + return _FakePtyProcess(); + }, + ); + + await repository.connect( + SavedHost.localShell(id: 'local', name: distro.name), + columns: 80, + rows: 24, + ); + + expect(capturedArguments, containsAllInOrder(['/bin/sh', '-l'])); + expect( + capturedArguments.any((argument) => argument.contains('@alpine')), + isTrue, + ); + }); }); } diff --git a/test/features/local_shell/proot_command_test.dart b/test/features/local_shell/proot_command_test.dart index ff0f782..093b362 100644 --- a/test/features/local_shell/proot_command_test.dart +++ b/test/features/local_shell/proot_command_test.dart @@ -43,6 +43,28 @@ void main() { ); }); + test('labels the prompt with the distro', () { + final labelled = builder.login( + rootfsDir: '/data/rootfs', + promptLabel: 'alpine', + ); + expect( + labelled.arguments.any((argument) => argument.contains('@alpine')), + isTrue, + ); + }); + + test('runScript uses POSIX sh', () { + final script = builder.runScript( + rootfsDir: '/data/rootfs', + script: 'echo hi', + ); + expect( + script.arguments, + containsAllInOrder(['/bin/sh', '-lc', 'echo hi']), + ); + }); + test('binds shared Android storage into the guest', () { const builder = ProotCommandBuilder( prootBinary: '/lib/libproot.so', diff --git a/test/features/local_shell/rootfs_manifest_test.dart b/test/features/local_shell/rootfs_manifest_test.dart index 79ca41e..35eca42 100644 --- a/test/features/local_shell/rootfs_manifest_test.dart +++ b/test/features/local_shell/rootfs_manifest_test.dart @@ -13,8 +13,6 @@ void main() { 'archiveUrl': 'https://mirror.example/arch-aarch64.tar.gz', 'sha256': abcSha, 'downloadSizeBytes': 178000000, - 'pacmanMirror': r'http://mirror.archlinuxarm.org/$arch/$repo', - 'keyringName': 'archlinuxarm', }; group('RootfsManifest.fromJson', () { @@ -24,12 +22,14 @@ void main() { expect(manifest.archiveUrl.host, 'mirror.example'); expect(manifest.sha256, abcSha); expect(manifest.downloadSizeBytes, 178000000); - expect(manifest.keyringName, 'archlinuxarm'); }); - test('defaults the keyring name when omitted', () { - final json = validJson()..remove('keyringName'); - expect(RootfsManifest.fromJson(json).keyringName, 'archlinuxarm'); + test('ignores unknown keys from older manifests', () { + final json = validJson() + ..['pacmanMirror'] = r'http://mirror.archlinuxarm.org/$arch/$repo' + ..['keyringName'] = 'archlinuxarm'; + final manifest = RootfsManifest.fromJson(json); + expect(manifest.version, '2026.06.01'); }); test('rejects a missing archive url', () { @@ -47,14 +47,6 @@ void main() { throwsA(isA()), ); }); - - test('rejects a missing mirror', () { - final json = validJson()..remove('pacmanMirror'); - expect( - () => RootfsManifest.fromJson(json), - throwsA(isA()), - ); - }); }); group('checksum verification', () { diff --git a/test/features/terminal/terminal_test.dart b/test/features/terminal/terminal_test.dart index 672f06d..273d0b5 100644 --- a/test/features/terminal/terminal_test.dart +++ b/test/features/terminal/terminal_test.dart @@ -705,6 +705,7 @@ void main() { fontSize: 14, onFontSizeChanged: (_) {}, predictiveEchoEnabled: false, + terminalMouseInput: false, focusNode: focusNode, tmuxScrollMode: true, onExitTmuxScrollMode: () {}, @@ -814,6 +815,35 @@ void main() { expect(session.sent.map(String.fromCharCodes), ['\r']); }, ); + + test('normalizes enter key output to the configured sequence', () async { + final session = FakePredictiveTerminalSession(); + final controller = TerminalSessionController( + host: buildHost('enter-sequence'), + repository: ImmediateTerminalRepository(session), + enterSequence: TerminalEnterSequence.crlf, + ); + addTearDown(controller.dispose); + + await controller.connect(); + controller.sendKey(TerminalKey.enter); + + expect(session.sent.map(String.fromCharCodes), ['\r\n']); + }); + + test('normalizes raw LF enter output to the configured sequence', () async { + final session = FakePredictiveTerminalSession(); + final controller = TerminalSessionController( + host: buildHost('enter-lf'), + repository: ImmediateTerminalRepository(session), + ); + addTearDown(controller.dispose); + + await controller.connect(); + controller.sendText('\n'); + + expect(session.sent.map(String.fromCharCodes), ['\r']); + }); }); group('OpenSSH security key signer', () {