From 2aff1fb57c7b401925bf68415b55a5b65c9c486b Mon Sep 17 00:00:00 2001 From: Dr Mulungu Date: Fri, 24 Jul 2026 03:32:34 -0500 Subject: [PATCH 1/2] feat: add six new dark palettes (#142) Horizon, Iceberg, Sonokai, Zenburn, Carbonfox, and Poimandres, using each scheme's canonical upstream colors, selectable from the appearance sheet. Every palette ships full dark terminal ANSI colors plus the shared light terminal fallback. Co-authored-by: Grzegorz Witkowski Co-authored-by: DrMulungu <182373227+DrMulungu@users.noreply.github.com> --- lib/core/theme/app_palette.dart | 276 ++++++++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) diff --git a/lib/core/theme/app_palette.dart b/lib/core/theme/app_palette.dart index fc88815..c7a2762 100644 --- a/lib/core/theme/app_palette.dart +++ b/lib/core/theme/app_palette.dart @@ -18,6 +18,12 @@ enum AppPalette { palenight, synthwave, githubDark, + horizon, + iceberg, + sonokai, + zenburn, + carbonfox, + poimandres, } /// To add a palette: add a value to [AppPalette], then add its `PaletteSpec` @@ -1094,5 +1100,275 @@ extension AppPaletteDetails on AppPalette { ), terminalLight: _lightFallbackTerminal, ), + AppPalette.horizon => const PaletteSpec( + label: 'Horizon', + caption: 'Warm dusk pinks and teals', + accent: Color(0xFFE95678), + accentSecondary: Color(0xFF26BBD9), + canvas: Color(0xFF16161C), + panel: Color(0xFF1C1E26), + panelElevated: Color(0xFF232530), + hairline: Color(0xFF2E303E), + border: Color(0xFF3A3D4D), + foreground: Color(0xFFD5D8DA), + mutedForeground: Color(0xFF9DA0A2), + subtleForeground: Color(0xFF6C6F93), + success: Color(0xFF29D398), + warning: Color(0xFFFAB795), + danger: Color(0xFFE95678), + canvasLight: Color(0xFFFDF0ED), + foregroundLight: Color(0xFF1C1E26), + terminalDark: TerminalTheme( + cursor: Color(0xFFE95678), + selection: Color(0x662E303E), + foreground: Color(0xFFD5D8DA), + background: Color(0xFF16161C), + black: Color(0xFF16161C), + red: Color(0xFFE95678), + green: Color(0xFF29D398), + yellow: Color(0xFFFAB795), + blue: Color(0xFF26BBD9), + magenta: Color(0xFFEE64AC), + cyan: Color(0xFF59E1E3), + white: Color(0xFFD5D8DA), + brightBlack: Color(0xFF6C6F93), + brightRed: Color(0xFFEC6A88), + brightGreen: Color(0xFF3FDAA4), + brightYellow: Color(0xFFFBC3A7), + brightBlue: Color(0xFF3FC4DE), + brightMagenta: Color(0xFFF075B5), + brightCyan: Color(0xFF6BE4E6), + brightWhite: Color(0xFFE5E5E5), + searchHitBackground: Color(0xFFFAB795), + searchHitBackgroundCurrent: Color(0xFF29D398), + searchHitForeground: Color(0xFF16161C), + ), + terminalLight: _lightFallbackTerminal, + ), + AppPalette.iceberg => const PaletteSpec( + label: 'Iceberg', + caption: 'Cool bluish depths', + accent: Color(0xFF84A0C6), + accentSecondary: Color(0xFFA093C7), + canvas: Color(0xFF101219), + panel: Color(0xFF161821), + panelElevated: Color(0xFF1E2132), + hairline: Color(0xFF272C42), + border: Color(0xFF33374C), + foreground: Color(0xFFC6C8D1), + mutedForeground: Color(0xFF8389A3), + subtleForeground: Color(0xFF6B7089), + success: Color(0xFFB4BE82), + warning: Color(0xFFE2A478), + danger: Color(0xFFE27878), + canvasLight: Color(0xFFE8E9EC), + foregroundLight: Color(0xFF33374C), + terminalDark: TerminalTheme( + cursor: Color(0xFFC6C8D1), + selection: Color(0x661E2132), + foreground: Color(0xFFC6C8D1), + background: Color(0xFF101219), + black: Color(0xFF1E2132), + red: Color(0xFFE27878), + green: Color(0xFFB4BE82), + yellow: Color(0xFFE2A478), + blue: Color(0xFF84A0C6), + magenta: Color(0xFFA093C7), + cyan: Color(0xFF89B8C2), + white: Color(0xFFC6C8D1), + brightBlack: Color(0xFF6B7089), + brightRed: Color(0xFFE98989), + brightGreen: Color(0xFFC0CA8E), + brightYellow: Color(0xFFE9B189), + brightBlue: Color(0xFF91ACD1), + brightMagenta: Color(0xFFADA0D3), + brightCyan: Color(0xFF95C4CE), + brightWhite: Color(0xFFD2D4DE), + searchHitBackground: Color(0xFFE2A478), + searchHitBackgroundCurrent: Color(0xFF84A0C6), + searchHitForeground: Color(0xFF101219), + ), + terminalLight: _lightFallbackTerminal, + ), + AppPalette.sonokai => const PaletteSpec( + label: 'Sonokai', + caption: 'High-contrast vivid shades', + accent: Color(0xFF9ED072), + accentSecondary: Color(0xFFB39DF3), + canvas: Color(0xFF22242A), + panel: Color(0xFF2C2E34), + panelElevated: Color(0xFF33353F), + hairline: Color(0xFF3B3E48), + border: Color(0xFF45484F), + foreground: Color(0xFFE2E2E3), + mutedForeground: Color(0xFF9DA0A8), + subtleForeground: Color(0xFF7F8490), + success: Color(0xFF9ED072), + warning: Color(0xFFE7C664), + danger: Color(0xFFFC5D7C), + canvasLight: Color(0xFFF4F3F1), + foregroundLight: Color(0xFF33353F), + terminalDark: TerminalTheme( + cursor: Color(0xFFE2E2E3), + selection: Color(0x66414550), + foreground: Color(0xFFE2E2E3), + background: Color(0xFF22242A), + black: Color(0xFF181A1C), + red: Color(0xFFFC5D7C), + green: Color(0xFF9ED072), + yellow: Color(0xFFE7C664), + blue: Color(0xFF76CCE0), + magenta: Color(0xFFB39DF3), + cyan: Color(0xFF76CCE0), + white: Color(0xFFE2E2E3), + brightBlack: Color(0xFF7F8490), + brightRed: Color(0xFFFC5D7C), + brightGreen: Color(0xFF9ED072), + brightYellow: Color(0xFFE7C664), + brightBlue: Color(0xFF76CCE0), + brightMagenta: Color(0xFFB39DF3), + brightCyan: Color(0xFF76CCE0), + brightWhite: Color(0xFFF4F3F1), + searchHitBackground: Color(0xFFE7C664), + searchHitBackgroundCurrent: Color(0xFF9ED072), + searchHitForeground: Color(0xFF22242A), + ), + terminalLight: _lightFallbackTerminal, + ), + AppPalette.zenburn => const PaletteSpec( + label: 'Zenburn', + caption: 'Low-contrast classic calm', + accent: Color(0xFFF0DFAF), + accentSecondary: Color(0xFF8CD0D3), + canvas: Color(0xFF313131), + panel: Color(0xFF3F3F3F), + panelElevated: Color(0xFF494949), + hairline: Color(0xFF525252), + border: Color(0xFF5F5F5F), + foreground: Color(0xFFDCDCCC), + mutedForeground: Color(0xFFA8A897), + subtleForeground: Color(0xFF709080), + success: Color(0xFF60B48A), + warning: Color(0xFFF0DFAF), + danger: Color(0xFFDCA3A3), + canvasLight: Color(0xFFF2F1E9), + foregroundLight: Color(0xFF3F3F3F), + terminalDark: TerminalTheme( + cursor: Color(0xFFDCDCCC), + selection: Color(0x66709080), + foreground: Color(0xFFDCDCCC), + background: Color(0xFF313131), + black: Color(0xFF1E2320), + red: Color(0xFF705050), + green: Color(0xFF60B48A), + yellow: Color(0xFFDFAF8F), + blue: Color(0xFF506070), + magenta: Color(0xFFDC8CC3), + cyan: Color(0xFF8CD0D3), + white: Color(0xFFDCDCCC), + brightBlack: Color(0xFF709080), + brightRed: Color(0xFFDCA3A3), + brightGreen: Color(0xFFC3BF9F), + brightYellow: Color(0xFFF0DFAF), + brightBlue: Color(0xFF94BFF3), + brightMagenta: Color(0xFFEC93D3), + brightCyan: Color(0xFF93E0E3), + brightWhite: Color(0xFFFFFFFF), + searchHitBackground: Color(0xFFF0DFAF), + searchHitBackgroundCurrent: Color(0xFF93E0E3), + searchHitForeground: Color(0xFF313131), + ), + terminalLight: _lightFallbackTerminal, + ), + AppPalette.carbonfox => const PaletteSpec( + label: 'Carbonfox', + caption: 'Carbon black, neon glow', + accent: Color(0xFF78A9FF), + accentSecondary: Color(0xFFBE95FF), + canvas: Color(0xFF0C0C0C), + panel: Color(0xFF161616), + panelElevated: Color(0xFF252525), + hairline: Color(0xFF2A2A2A), + border: Color(0xFF3C3C3C), + foreground: Color(0xFFF2F4F8), + mutedForeground: Color(0xFFB6B8BB), + subtleForeground: Color(0xFF7B7C7E), + success: Color(0xFF25BE6A), + warning: Color(0xFF08BDBA), + danger: Color(0xFFEE5396), + canvasLight: Color(0xFFF4F4F5), + foregroundLight: Color(0xFF161616), + terminalDark: TerminalTheme( + cursor: Color(0xFFF2F4F8), + selection: Color(0x662A2A2A), + foreground: Color(0xFFF2F4F8), + background: Color(0xFF0C0C0C), + black: Color(0xFF282828), + red: Color(0xFFEE5396), + green: Color(0xFF25BE6A), + yellow: Color(0xFF08BDBA), + blue: Color(0xFF78A9FF), + magenta: Color(0xFFBE95FF), + cyan: Color(0xFF33B1FF), + white: Color(0xFFDFDFE0), + brightBlack: Color(0xFF484848), + brightRed: Color(0xFFF16DA6), + brightGreen: Color(0xFF46C880), + brightYellow: Color(0xFF2DC7C4), + brightBlue: Color(0xFF8CB6FF), + brightMagenta: Color(0xFFC8A5FF), + brightCyan: Color(0xFF52BDFF), + brightWhite: Color(0xFFE4E4E5), + searchHitBackground: Color(0xFF08BDBA), + searchHitBackgroundCurrent: Color(0xFF78A9FF), + searchHitForeground: Color(0xFF0C0C0C), + ), + terminalLight: _lightFallbackTerminal, + ), + AppPalette.poimandres => const PaletteSpec( + label: 'Poimandres', + caption: 'Minimal teal on deep slate', + accent: Color(0xFF5DE4C7), + accentSecondary: Color(0xFF89DDFF), + canvas: Color(0xFF171922), + panel: Color(0xFF1B1E28), + panelElevated: Color(0xFF252B37), + hairline: Color(0xFF2C3341), + border: Color(0xFF3A4150), + foreground: Color(0xFFE4F0FB), + mutedForeground: Color(0xFFA6ACCD), + subtleForeground: Color(0xFF767C9D), + success: Color(0xFF5DE4C7), + warning: Color(0xFFFFFAC2), + danger: Color(0xFFD0679D), + canvasLight: Color(0xFFEFF3F9), + foregroundLight: Color(0xFF1B1E28), + terminalDark: TerminalTheme( + cursor: Color(0xFF5DE4C7), + selection: Color(0x66303340), + foreground: Color(0xFFE4F0FB), + background: Color(0xFF171922), + black: Color(0xFF1B1E28), + red: Color(0xFFD0679D), + green: Color(0xFF5DE4C7), + yellow: Color(0xFFFFFAC2), + blue: Color(0xFF89DDFF), + magenta: Color(0xFFFCC5E9), + cyan: Color(0xFFADD7FF), + white: Color(0xFFFFFFFF), + brightBlack: Color(0xFF767C9D), + brightRed: Color(0xFFD0679D), + brightGreen: Color(0xFF5DE4C7), + brightYellow: Color(0xFFFFFAC2), + brightBlue: Color(0xFF89DDFF), + brightMagenta: Color(0xFFFCC5E9), + brightCyan: Color(0xFFADD7FF), + brightWhite: Color(0xFFFFFFFF), + searchHitBackground: Color(0xFFFFFAC2), + searchHitBackgroundCurrent: Color(0xFF5DE4C7), + searchHitForeground: Color(0xFF171922), + ), + terminalLight: _lightFallbackTerminal, + ), }; } From fe3de6470d6bcffa7dcfcc1265e378d38e2f9319 Mon Sep 17 00:00:00 2001 From: Dr Mulungu Date: Fri, 24 Jul 2026 03:41:33 -0500 Subject: [PATCH 2/2] Upload phone files over SFTP and insert remote paths (#149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: upload phone files over SFTP from the terminal key row Add an Upload key to the terminal keyboard bar: pick one or more phone files (photos, videos, documents) with the native picker, stream them to the active SSH host over SFTP, and insert the shell-quoted remote paths into the terminal without executing anything. Uploads land in an app-managed directory — ~/.conduit/uploads// by default, configurable per machine (absolute or ~/-relative) in the host form. Directories are created as needed, picked filenames are sanitized to a single path segment with control characters stripped, collisions get a compact time suffix before the extension, and transfers stream chunk-by-chunk through the existing SftpSession.write path with live progress and cancellation (cancel closes the batch session). Every uploaded file is recorded in a per-host manifest (conduit.upload_manifest.v1, defensive parsing). The new per-machine 'Delete my uploads after' setting (never/7/30/90 days) removes only manifest-listed files past the cutoff during later uploads — never arbitrary directories or files Conduit didn't write. Path insertion quotes each path for POSIX shells (spaces, quotes, parentheses, dollar signs, newlines) and joins multiple files with spaces. Insertion never submits, so the user can compose text around the paths. The key ships in the default layout and rides the unseen built-ins migration; local-shell sessions explain that uploads need an SSH machine. New SavedHost fields ride backup export/import via the existing host serialization. * fix: record partial upload batches in the manifest; safer cancel Review follow-ups: files that reached the server are now recorded in the per-host manifest even when a later file fails or the batch is cancelled mid-write, so automatic cleanup can always manage them. Cleanup of expired earlier uploads now runs only after a fully successful batch — failed or cancelled batches never delete anything. Cancelling during connect no longer creates remote directories, and a manifest write failure no longer misreports a successful upload as failed. --------- Co-authored-by: Grzegorz Witkowski Co-authored-by: DrMulungu <182373227+DrMulungu@users.noreply.github.com> --- README.md | 4 + lib/core/presentation/theme_sheet.dart | 1 + lib/core/theme/terminal_appearance.dart | 4 + lib/features/hosts/domain/saved_host.dart | 25 + .../hosts/presentation/host_form_page.dart | 11 + .../hosts/presentation/hosts_page.dart | 5 + .../widgets/host_form_sections.dart | 41 ++ .../secure_upload_manifest_repository.dart | 70 +++ lib/features/sftp/domain/upload_manifest.dart | 44 ++ lib/features/sftp/domain/upload_path.dart | 99 ++++ .../presentation/terminal_keyboard_bar.dart | 11 + .../terminal/presentation/terminal_page.dart | 97 ++++ .../terminal_upload_controller.dart | 288 +++++++++++ .../widgets/terminal_upload_sheet.dart | 242 ++++++++++ lib/main.dart | 9 + pubspec.lock | 2 +- pubspec.yaml | 1 + test/core/system_navigation_insets_test.dart | 4 + .../theme_preferences_repository_test.dart | 2 + test/features/hosts/hosts_test.dart | 19 + test/features/sftp/upload_manifest_test.dart | 68 +++ test/features/sftp/upload_path_test.dart | 131 +++++ .../terminal/terminal_upload_test.dart | 449 ++++++++++++++++++ test/support/test_doubles.dart | 53 ++- 24 files changed, 1675 insertions(+), 5 deletions(-) create mode 100644 lib/features/sftp/data/secure_upload_manifest_repository.dart create mode 100644 lib/features/sftp/domain/upload_manifest.dart create mode 100644 lib/features/sftp/domain/upload_path.dart create mode 100644 lib/features/terminal/presentation/terminal_upload_controller.dart create mode 100644 lib/features/terminal/presentation/widgets/terminal_upload_sheet.dart create mode 100644 test/features/sftp/upload_manifest_test.dart create mode 100644 test/features/sftp/upload_path_test.dart create mode 100644 test/features/terminal/terminal_upload_test.dart diff --git a/README.md b/README.md index aecc6a5..2e5f616 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ Dart implementation of the protocol, and the terminal is password. - Customizable on-screen key row with modifiers, arrows, function keys, key repeat, latching modifiers, and your own text snippets and control-key combos. +- Upload key: pick phone files, photos, or videos, stream them to the machine + over SFTP into `~/.conduit/uploads//`, and insert the shell-quoted + remote path into the terminal, with per-machine directory and optional + cleanup of Conduit's own old uploads. - Saved global and per-machine snippets from the key row, with hidden snippets for passwords or secrets and optional per-machine run-on-connect snippets. - Optional device-auth app lock for protecting saved machines and credentials. diff --git a/lib/core/presentation/theme_sheet.dart b/lib/core/presentation/theme_sheet.dart index fa66ad0..7da1637 100644 --- a/lib/core/presentation/theme_sheet.dart +++ b/lib/core/presentation/theme_sheet.dart @@ -1078,6 +1078,7 @@ IconData _keyboardActionIcon(TerminalKeyboardAction action) { TerminalKeyboardAction.tmuxMenu => Icons.view_quilt_rounded, TerminalKeyboardAction.snippets => Icons.snippet_folder_rounded, TerminalKeyboardAction.compose => Icons.edit_note_rounded, + TerminalKeyboardAction.upload => Icons.upload_file_rounded, }; } diff --git a/lib/core/theme/terminal_appearance.dart b/lib/core/theme/terminal_appearance.dart index 3b1078e..0f7fff1 100644 --- a/lib/core/theme/terminal_appearance.dart +++ b/lib/core/theme/terminal_appearance.dart @@ -79,6 +79,7 @@ enum TerminalKeyboardAction { tmuxMenu, snippets, compose, + upload, } enum TerminalKeyboardItemKind { builtIn, customText, customControl } @@ -170,6 +171,7 @@ const defaultTerminalKeyboardActions = [ TerminalKeyboardAction.tmuxScrollback, TerminalKeyboardAction.tmuxMenu, TerminalKeyboardAction.snippets, + TerminalKeyboardAction.upload, TerminalKeyboardAction.fullscreen, ]; @@ -262,6 +264,7 @@ const defaultTerminalKeyboardItems = [ TerminalKeyboardItem.builtIn(TerminalKeyboardAction.tmuxScrollback), TerminalKeyboardItem.builtIn(TerminalKeyboardAction.tmuxMenu), TerminalKeyboardItem.builtIn(TerminalKeyboardAction.snippets), + TerminalKeyboardItem.builtIn(TerminalKeyboardAction.upload), TerminalKeyboardItem.builtIn(TerminalKeyboardAction.fullscreen), ]; @@ -383,5 +386,6 @@ extension TerminalKeyboardActionDetails on TerminalKeyboardAction { TerminalKeyboardAction.tmuxMenu => 'Tmux+', TerminalKeyboardAction.snippets => 'Snip', TerminalKeyboardAction.compose => 'Compose', + TerminalKeyboardAction.upload => 'Upload', }; } diff --git a/lib/features/hosts/domain/saved_host.dart b/lib/features/hosts/domain/saved_host.dart index 55af2cb..c5a623e 100644 --- a/lib/features/hosts/domain/saved_host.dart +++ b/lib/features/hosts/domain/saved_host.dart @@ -148,6 +148,8 @@ class SavedHost { this.tmuxStartDirectory = '', this.snippets = const [], this.connectSnippetId = '', + this.uploadDirectory = '', + this.uploadCleanupDays, this.lastConnectedAt, this.isLocal = false, }); @@ -188,6 +190,15 @@ class SavedHost { final String tmuxStartDirectory; final List snippets; final String connectSnippetId; + + /// Remote directory for phone-file uploads. Empty means the default + /// `~/.conduit/uploads/`; may be absolute or `~/`-relative. + final String uploadDirectory; + + /// Age in days after which Conduit-uploaded files are deleted on the next + /// upload. Null disables automatic cleanup (the default). + final int? uploadCleanupDays; + final DateTime? lastConnectedAt; final bool isLocal; @@ -261,6 +272,9 @@ class SavedHost { String? tmuxStartDirectory, List? snippets, String? connectSnippetId, + String? uploadDirectory, + int? uploadCleanupDays, + bool clearUploadCleanupDays = false, DateTime? lastConnectedAt, bool clearLastConnectedAt = false, bool? isLocal, @@ -292,6 +306,10 @@ class SavedHost { tmuxStartDirectory: tmuxStartDirectory ?? this.tmuxStartDirectory, snippets: snippets ?? this.snippets, connectSnippetId: connectSnippetId ?? this.connectSnippetId, + uploadDirectory: uploadDirectory ?? this.uploadDirectory, + uploadCleanupDays: clearUploadCleanupDays + ? null + : uploadCleanupDays ?? this.uploadCleanupDays, lastConnectedAt: clearLastConnectedAt ? null : lastConnectedAt ?? this.lastConnectedAt, @@ -332,6 +350,8 @@ class SavedHost { 'tmuxStartDirectory': tmuxStartDirectory, 'snippets': [for (final snippet in snippets) snippet.toJson()], 'connectSnippetId': connectSnippetId, + 'uploadDirectory': uploadDirectory, + 'uploadCleanupDays': uploadCleanupDays, 'lastConnectedAt': lastConnectedAt?.toIso8601String(), 'isLocal': isLocal, }; @@ -386,6 +406,11 @@ class SavedHost { .whereType() .toList(growable: false), connectSnippetId: json['connectSnippetId'] as String? ?? '', + uploadDirectory: (json['uploadDirectory'] as String?)?.trim() ?? '', + uploadCleanupDays: switch (json['uploadCleanupDays']) { + final int days when days > 0 => days, + _ => null, + }, lastConnectedAt: lastConnectedAtRaw == null ? null : DateTime.tryParse(lastConnectedAtRaw), diff --git a/lib/features/hosts/presentation/host_form_page.dart b/lib/features/hosts/presentation/host_form_page.dart index 05a4799..119d9ee 100644 --- a/lib/features/hosts/presentation/host_form_page.dart +++ b/lib/features/hosts/presentation/host_form_page.dart @@ -51,11 +51,13 @@ class _HostFormPageState extends State { text: defaultTmuxSessionName, ); final _tmuxStartDirectoryController = TextEditingController(); + final _uploadDirectoryController = TextEditingController(); final FocusNode _tagFocusNode = FocusNode(); SshAuthMethod _authMethod = SshAuthMethod.password; bool _showPassword = false; bool _showPassphrase = false; bool _useMosh = false; + int? _uploadCleanupDays; bool _predictiveEchoEnabled = false; bool _externalAuthOfferKey = true; bool _forwardAgent = false; @@ -116,6 +118,8 @@ class _HostFormPageState extends State { _tmuxStartDirectoryController.text = host.tmuxStartDirectory; _snippets = List.from(host.snippets); _connectSnippetId = host.connectSnippetId; + _uploadDirectoryController.text = host.uploadDirectory; + _uploadCleanupDays = host.uploadCleanupDays; } _keyInspection = _cheapPreview(); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -196,6 +200,7 @@ class _HostFormPageState extends State { _moshPortsController.dispose(); _tmuxSessionNameController.dispose(); _tmuxStartDirectoryController.dispose(); + _uploadDirectoryController.dispose(); super.dispose(); } @@ -290,10 +295,12 @@ class _HostFormPageState extends State { moshPortsController: _moshPortsController, tmuxSessionNameController: _tmuxSessionNameController, tmuxStartDirectoryController: _tmuxStartDirectoryController, + uploadDirectoryController: _uploadDirectoryController, useMosh: _useMosh, predictiveEchoEnabled: _predictiveEchoEnabled, startTmuxOnConnect: _startTmuxOnConnect, tmuxPrefixKey: _tmuxPrefixKey, + uploadCleanupDays: _uploadCleanupDays, snippets: _snippets, connectSnippetId: _connectSnippetId, timeoutValidator: _validateTimeout, @@ -312,6 +319,8 @@ class _HostFormPageState extends State { setState(() => _startTmuxOnConnect = value), onTmuxPrefixKeyChanged: (value) => setState(() => _tmuxPrefixKey = value), + onUploadCleanupDaysChanged: (value) => + setState(() => _uploadCleanupDays = value), onSnippetsChanged: (snippets) => setState(() { _snippets = snippets; if (!_snippets.any( @@ -717,6 +726,8 @@ class _HostFormPageState extends State { ? defaultTmuxSessionName : _tmuxSessionNameController.text.trim(), tmuxStartDirectory: _tmuxStartDirectoryController.text.trim(), + uploadDirectory: _uploadDirectoryController.text.trim(), + uploadCleanupDays: _uploadCleanupDays, snippets: List.unmodifiable(_snippets), connectSnippetId: _snippets.any((snippet) => snippet.id == _connectSnippetId) diff --git a/lib/features/hosts/presentation/hosts_page.dart b/lib/features/hosts/presentation/hosts_page.dart index b33c1f5..79f3a22 100644 --- a/lib/features/hosts/presentation/hosts_page.dart +++ b/lib/features/hosts/presentation/hosts_page.dart @@ -23,6 +23,7 @@ import 'package:conduit/features/local_shell/presentation/local_shell_setup_page 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/domain/upload_manifest.dart'; import 'package:conduit/features/sftp/presentation/sftp_browser_page.dart'; import 'package:conduit/features/terminal/domain/host_key_prompt.dart'; import 'package:conduit/features/terminal/domain/host_key_verifier.dart'; @@ -47,6 +48,7 @@ class HostsPage extends StatefulWidget { required this.hostKeyVerifier, required this.promptCoordinator, required this.sftpRepository, + required this.uploadManifestRepository, required this.backupService, required this.fileExport, super.key, @@ -61,6 +63,7 @@ class HostsPage extends StatefulWidget { final HostKeyVerifier hostKeyVerifier; final HostKeyPromptCoordinator promptCoordinator; final SftpRepository sftpRepository; + final UploadManifestRepository uploadManifestRepository; final AppBackupService backupService; final FileExport fileExport; @@ -440,6 +443,8 @@ class _HostsPageState extends State { builder: (_) => TerminalPage( workspace: widget.workspaceController, themeController: widget.themeController, + sftpRepository: widget.sftpRepository, + uploadManifestRepository: widget.uploadManifestRepository, ), ), ); diff --git a/lib/features/hosts/presentation/widgets/host_form_sections.dart b/lib/features/hosts/presentation/widgets/host_form_sections.dart index f9111cd..bace466 100644 --- a/lib/features/hosts/presentation/widgets/host_form_sections.dart +++ b/lib/features/hosts/presentation/widgets/host_form_sections.dart @@ -299,10 +299,12 @@ class HostAdvancedSection extends StatelessWidget { required this.moshPortsController, required this.tmuxSessionNameController, required this.tmuxStartDirectoryController, + required this.uploadDirectoryController, required this.useMosh, required this.predictiveEchoEnabled, required this.startTmuxOnConnect, required this.tmuxPrefixKey, + required this.uploadCleanupDays, required this.snippets, required this.connectSnippetId, required this.timeoutValidator, @@ -313,6 +315,7 @@ class HostAdvancedSection extends StatelessWidget { required this.onPredictiveEchoChanged, required this.onStartTmuxOnConnectChanged, required this.onTmuxPrefixKeyChanged, + required this.onUploadCleanupDaysChanged, required this.onSnippetsChanged, required this.onConnectSnippetChanged, super.key, @@ -326,10 +329,12 @@ class HostAdvancedSection extends StatelessWidget { final TextEditingController moshPortsController; final TextEditingController tmuxSessionNameController; final TextEditingController tmuxStartDirectoryController; + final TextEditingController uploadDirectoryController; final bool useMosh; final bool predictiveEchoEnabled; final bool startTmuxOnConnect; final TmuxPrefixKey tmuxPrefixKey; + final int? uploadCleanupDays; final List snippets; final String connectSnippetId; final FormFieldValidator timeoutValidator; @@ -340,6 +345,7 @@ class HostAdvancedSection extends StatelessWidget { final ValueChanged onPredictiveEchoChanged; final ValueChanged onStartTmuxOnConnectChanged; final ValueChanged onTmuxPrefixKeyChanged; + final ValueChanged onUploadCleanupDaysChanged; final ValueChanged> onSnippetsChanged; final ValueChanged onConnectSnippetChanged; @@ -494,6 +500,41 @@ class HostAdvancedSection extends StatelessWidget { } }, ), + const SizedBox(height: 16), + TextFormField( + controller: uploadDirectoryController, + decoration: const InputDecoration( + labelText: 'Upload directory', + hintText: '~/.conduit/uploads', + helperText: + 'Where the key-row Upload key stores phone files. Empty uses ' + '~/.conduit/uploads with one folder per day.', + helperMaxLines: 3, + prefixIcon: Icon(Icons.drive_folder_upload_outlined), + ), + autocorrect: false, + enableSuggestions: false, + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: uploadCleanupDays, + decoration: const InputDecoration( + labelText: 'Delete my uploads after', + helperText: + 'Only files Conduit itself uploaded are ever deleted, on the ' + 'next upload to this machine.', + helperMaxLines: 3, + prefixIcon: Icon(Icons.auto_delete_outlined), + ), + items: const [ + DropdownMenuItem(child: Text('Never')), + DropdownMenuItem(value: 7, child: Text('7 days')), + DropdownMenuItem(value: 30, child: Text('30 days')), + DropdownMenuItem(value: 90, child: Text('90 days')), + ], + onChanged: onUploadCleanupDaysChanged, + ), const SizedBox(height: 18), SnippetListEditor( title: 'Host snippets', diff --git a/lib/features/sftp/data/secure_upload_manifest_repository.dart b/lib/features/sftp/data/secure_upload_manifest_repository.dart new file mode 100644 index 0000000..86cd189 --- /dev/null +++ b/lib/features/sftp/data/secure_upload_manifest_repository.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Stores the per-host upload manifest as one JSON map +/// (`hostId -> [{path, at}]`) in secure storage. Corrupt payloads load as +/// an empty manifest; removing a host's last entry drops the host key. +class SecureUploadManifestRepository implements UploadManifestRepository { + const SecureUploadManifestRepository(this._storage); + + static const _key = 'conduit.upload_manifest.v1'; + + final FlutterSecureStorage _storage; + + @override + Future> entriesFor(String hostId) async { + final all = await _loadAll(); + return all[hostId] ?? const []; + } + + @override + Future setEntries( + String hostId, + List entries, + ) async { + final all = await _loadAll(); + if (entries.isEmpty) { + all.remove(hostId); + } else { + all[hostId] = List.of(entries); + } + await _storage.write( + key: _key, + value: jsonEncode({ + for (final MapEntry(:key, :value) in all.entries) + key: [for (final entry in value) entry.toJson()], + }), + ); + } + + Future>> _loadAll() async { + final raw = await _storage.read(key: _key); + if (raw == null || raw.trim().isEmpty) { + return {}; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return {}; + } + final result = >{}; + for (final MapEntry(:key, :value) in decoded.entries) { + if (key is! String || value is! List) { + continue; + } + final entries = value + .map(UploadManifestEntry.fromJson) + .whereType() + .toList(); + if (entries.isNotEmpty) { + result[key] = entries; + } + } + return result; + } catch (_) { + return {}; + } + } +} diff --git a/lib/features/sftp/domain/upload_manifest.dart b/lib/features/sftp/domain/upload_manifest.dart new file mode 100644 index 0000000..16698a0 --- /dev/null +++ b/lib/features/sftp/domain/upload_manifest.dart @@ -0,0 +1,44 @@ +/// A record of one file Conduit uploaded to a host, used so automatic +/// cleanup can only ever touch files the app itself wrote. +class UploadManifestEntry { + const UploadManifestEntry({required this.path, required this.uploadedAt}); + + final String path; + final DateTime uploadedAt; + + Map toJson() { + return {'path': path, 'at': uploadedAt.toUtc().millisecondsSinceEpoch}; + } + + static UploadManifestEntry? fromJson(Object? json) { + if (json is! Map) { + return null; + } + final path = json['path']; + final at = json['at']; + if (path is! String || path.isEmpty || at is! int) { + return null; + } + return UploadManifestEntry( + path: path, + uploadedAt: DateTime.fromMillisecondsSinceEpoch(at, isUtc: true), + ); + } + + @override + bool operator ==(Object other) { + return other is UploadManifestEntry && + other.path == path && + other.uploadedAt == uploadedAt; + } + + @override + int get hashCode => Object.hash(path, uploadedAt); +} + +/// Per-host ledger of Conduit-owned remote uploads. +abstract class UploadManifestRepository { + Future> entriesFor(String hostId); + + Future setEntries(String hostId, List entries); +} diff --git a/lib/features/sftp/domain/upload_path.dart b/lib/features/sftp/domain/upload_path.dart new file mode 100644 index 0000000..4fc1dab --- /dev/null +++ b/lib/features/sftp/domain/upload_path.dart @@ -0,0 +1,99 @@ +/// Pure helpers for building safe remote upload paths. +library; + +/// Default remote directory (under the user's home) that uploads land in, +/// with one dated subdirectory per day. +const defaultUploadDirectoryName = '.conduit/uploads'; + +/// Formats the dated subdirectory for an upload batch: `yyyy-mm-dd`. +String uploadDateDirectory(DateTime now) { + String pad(int value) => value.toString().padLeft(2, '0'); + return '${now.year.toString().padLeft(4, '0')}-' + '${pad(now.month)}-${pad(now.day)}'; +} + +/// Resolves the absolute remote upload directory for a host. +/// +/// [home] is the server-resolved home directory. A [custom] directory from +/// host settings may be absolute, `~/`-relative, or home-relative; when +/// absent, uploads go to `$home/.conduit/uploads/`. +String resolveUploadDirectory({ + required String home, + required DateTime now, + String? custom, +}) { + final base = custom?.trim(); + final root = home.endsWith('/') ? home.substring(0, home.length - 1) : home; + if (base == null || base.isEmpty) { + return '$root/$defaultUploadDirectoryName/${uploadDateDirectory(now)}'; + } + if (base.startsWith('/')) { + return _stripTrailingSlash(base); + } + if (base == '~') { + return root; + } + if (base.startsWith('~/')) { + return _stripTrailingSlash('$root/${base.substring(2)}'); + } + return _stripTrailingSlash('$root/$base'); +} + +String _stripTrailingSlash(String path) { + return path.length > 1 && path.endsWith('/') + ? path.substring(0, path.length - 1) + : path; +} + +/// Makes a picked filename safe to use as a single remote path segment. +/// +/// Path separators and control characters are removed so a hostile or odd +/// filename cannot escape the upload directory; everything else — spaces, +/// Unicode, punctuation — is preserved for recognizability because inserted +/// paths are always shell-quoted separately. +String sanitizeUploadFileName(String name) { + var base = name.split('/').last.split(r'\').last; + base = base.replaceAll(RegExp(r'[\x00-\x1f\x7f]'), '').trim(); + if (base.isEmpty || base == '.' || base == '..') { + return 'file'; + } + return base; +} + +/// Picks a name that does not collide with [existing] entries, preserving +/// the extension: `photo.jpg` becomes `photo-143502.jpg` (upload time), and +/// `photo-143502-2.jpg` if even that is taken. +String resolveUploadCollision(String name, Set existing, DateTime now) { + if (!existing.contains(name)) { + return name; + } + final dot = name.lastIndexOf('.'); + final stem = dot > 0 ? name.substring(0, dot) : name; + final extension = dot > 0 ? name.substring(dot) : ''; + String pad(int value) => value.toString().padLeft(2, '0'); + final stamp = '${pad(now.hour)}${pad(now.minute)}${pad(now.second)}'; + var candidate = '$stem-$stamp$extension'; + var counter = 2; + while (existing.contains(candidate)) { + candidate = '$stem-$stamp-$counter$extension'; + counter += 1; + } + return candidate; +} + +const _posixSafePattern = r'^[A-Za-z0-9._/\-]+$'; + +/// Quotes [value] for safe literal use in a POSIX shell command line. +/// +/// Values made only of unambiguously safe characters pass through unquoted; +/// everything else is wrapped in single quotes with embedded single quotes +/// escaped as `'\''`, which keeps spaces, quotes, `$`, parentheses, newlines, +/// and globs literal in sh/bash/zsh/fish. +String posixShellQuote(String value) { + if (value.isNotEmpty && + !value.startsWith('-') && + RegExp(_posixSafePattern).hasMatch(value)) { + return value; + } + return "'${value.replaceAll("'", "'\\''")}'"; +} diff --git a/lib/features/terminal/presentation/terminal_keyboard_bar.dart b/lib/features/terminal/presentation/terminal_keyboard_bar.dart index a44f0ea..80393dd 100644 --- a/lib/features/terminal/presentation/terminal_keyboard_bar.dart +++ b/lib/features/terminal/presentation/terminal_keyboard_bar.dart @@ -23,6 +23,7 @@ class TerminalKeyboardBar extends StatelessWidget { required this.onToggleFullscreen, this.composeActive = false, this.onToggleCompose, + this.onUpload, required this.onEnterTmuxScrollMode, required this.onExitTmuxScrollMode, required this.tmuxPrefixKey, @@ -40,6 +41,9 @@ class TerminalKeyboardBar extends StatelessWidget { final VoidCallback onToggleFullscreen; final bool composeActive; final VoidCallback? onToggleCompose; + + /// Starts the phone-file upload flow for the active session. + final VoidCallback? onUpload; final VoidCallback onEnterTmuxScrollMode; final VoidCallback onExitTmuxScrollMode; final TmuxPrefixKey tmuxPrefixKey; @@ -232,6 +236,12 @@ class TerminalKeyboardBar extends StatelessWidget { onSelected: _triggerSnippetMenuItem, items: _snippetMenuItems(), ), + TerminalKeyboardAction.upload => _Key( + icon: Icons.upload_file_rounded, + palette: palette, + brightness: brightness, + onPressed: onUpload == null ? null : () => onUpload!(), + ), _ => _Key( label: action.label, palette: palette, @@ -286,6 +296,7 @@ class TerminalKeyboardBar extends StatelessWidget { case TerminalKeyboardAction.tmuxMenu: case TerminalKeyboardAction.snippets: case TerminalKeyboardAction.compose: + case TerminalKeyboardAction.upload: break; } } diff --git a/lib/features/terminal/presentation/terminal_page.dart b/lib/features/terminal/presentation/terminal_page.dart index 5a0912f..6021aa8 100644 --- a/lib/features/terminal/presentation/terminal_page.dart +++ b/lib/features/terminal/presentation/terminal_page.dart @@ -5,30 +5,47 @@ import 'package:conduit/core/presentation/system_navigation_insets.dart'; import 'package:conduit/core/theme/app_palette.dart'; import 'package:conduit/core/theme/terminal_appearance.dart'; import 'package:conduit/core/theme/theme_controller.dart'; +import 'package:conduit/features/sftp/domain/sftp_repository.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; +import 'package:conduit/features/sftp/presentation/sftp_browser_controller.dart' + show SftpUploadFile; import 'package:conduit/features/terminal/domain/security_key_interaction.dart'; import 'package:conduit/features/terminal/presentation/security_key_picker_dialog.dart'; import 'package:conduit/features/terminal/presentation/security_key_pin_dialog.dart'; import 'package:conduit/features/terminal/presentation/terminal_keyboard_bar.dart'; import 'package:conduit/features/terminal/presentation/terminal_session_controller.dart'; +import 'package:conduit/features/terminal/presentation/terminal_upload_controller.dart'; import 'package:conduit/features/terminal/presentation/terminal_workspace_controller.dart'; import 'package:conduit/features/terminal/presentation/widgets/empty_terminal_state.dart'; import 'package:conduit/features/terminal/presentation/widgets/session_tabs.dart'; import 'package:conduit/features/terminal/presentation/widgets/terminal_header.dart'; import 'package:conduit/features/terminal/presentation/widgets/terminal_surface.dart'; +import 'package:conduit/features/terminal/presentation/widgets/terminal_upload_sheet.dart'; import 'package:conduit_vt/conduit_vt.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; +/// Picks local files for upload; injectable so tests can avoid the native +/// file picker. Returns null when the user cancels. +typedef TerminalUploadFilePicker = Future?> Function(); + class TerminalPage extends StatefulWidget { const TerminalPage({ required this.workspace, required this.themeController, + required this.sftpRepository, + required this.uploadManifestRepository, + this.uploadFilePicker, super.key, }); final TerminalWorkspaceController workspace; final ThemeController themeController; + final SftpRepository sftpRepository; + final UploadManifestRepository uploadManifestRepository; + final TerminalUploadFilePicker? uploadFilePicker; @override State createState() => _TerminalPageState(); @@ -108,6 +125,84 @@ class _TerminalPageState extends State { _setSystemUiFullscreen(_fullscreen); } + Future?> _pickUploadFiles() async { + final custom = widget.uploadFilePicker; + if (custom != null) { + return custom(); + } + final FilePickerResult? result = await FilePicker.pickFiles( + allowMultiple: true, + withReadStream: true, + ); + if (result == null || result.files.isEmpty) { + return null; + } + final files = []; + for (final file in result.files) { + final readStream = file.readStream; + final path = file.path; + if (readStream == null && path == null) { + continue; + } + files.add( + readStream == null + ? SftpUploadFile.local( + localPath: path!, + name: file.name, + size: file.size, + ) + : SftpUploadFile( + source: () => readStream, + name: file.name, + size: file.size, + ), + ); + } + return files; + } + + Future _openUploadFlow(TerminalSessionController session) async { + if (session.host.isLocal) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Uploads need an SSH machine; the local shell has ' + 'no SFTP endpoint.', + ), + ), + ); + return; + } + final List? files; + try { + files = await _pickUploadFiles(); + } catch (error) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not read the selected files.')), + ); + } + return; + } + if (files == null || files.isEmpty || !mounted) { + return; + } + final controller = TerminalUploadController( + host: session.host, + repository: widget.sftpRepository, + manifest: widget.uploadManifestRepository, + ); + controller.prepare(files); + await showTerminalUploadSheet( + context: context, + controller: controller, + // Insert without submitting so the user can add text around the paths. + onInsert: session.sendText, + ); + controller.dispose(); + _focusNode.requestFocus(); + } + void _setSystemUiFullscreen(bool fullscreen) { SystemChrome.setEnabledSystemUIMode( fullscreen ? SystemUiMode.immersiveSticky : SystemUiMode.edgeToEdge, @@ -265,6 +360,8 @@ class _TerminalPageState extends State { composeActive: _composeMode, onToggleCompose: () => setState(() => _composeMode = !_composeMode), + onUpload: () => + unawaited(_openUploadFlow(activeSession)), tmuxPrefixKey: activeSession.host.tmuxPrefixKey, tmuxScrollMode: _tmuxScrollMode, onEnterTmuxScrollMode: () { diff --git a/lib/features/terminal/presentation/terminal_upload_controller.dart b/lib/features/terminal/presentation/terminal_upload_controller.dart new file mode 100644 index 0000000..ab605e3 --- /dev/null +++ b/lib/features/terminal/presentation/terminal_upload_controller.dart @@ -0,0 +1,288 @@ +// ignore_for_file: prefer_initializing_formals + +import 'dart:async'; + +import 'package:conduit/features/hosts/domain/saved_host.dart'; +import 'package:conduit/features/sftp/domain/sftp_entry.dart'; +import 'package:conduit/features/sftp/domain/sftp_repository.dart'; +import 'package:conduit/features/sftp/domain/sftp_session.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; +import 'package:conduit/features/sftp/domain/upload_path.dart'; +import 'package:conduit/features/sftp/presentation/sftp_browser_controller.dart' + show SftpUploadFile; +import 'package:flutter/foundation.dart'; + +enum TerminalUploadPhase { + confirming, + connecting, + uploading, + success, + failed, + cancelled, +} + +/// Progress and result for one file within an upload batch. +class TerminalUploadItem { + TerminalUploadItem(this.file); + + final SftpUploadFile file; + String? remotePath; + int bytesSent = 0; + bool done = false; + + double? get fraction { + if (file.size <= 0) { + return done ? 1 : null; + } + return (bytesSent / file.size).clamp(0.0, 1.0); + } +} + +/// Uploads one batch of picked phone files to a host over SFTP. +/// +/// Owns a dedicated SFTP session for the batch (opened lazily, always closed +/// when the batch ends), builds the app-managed remote directory, avoids +/// name collisions, records every written file in the per-host upload +/// manifest, and — when the host opts into cleanup — deletes only +/// manifest-listed files older than the configured age. Nothing outside the +/// manifest is ever deleted, and directories are never removed. +class TerminalUploadController extends ChangeNotifier { + TerminalUploadController({ + required this.host, + required SftpRepository repository, + required UploadManifestRepository manifest, + DateTime Function()? now, + }) : _repository = repository, + _manifest = manifest, + _now = now ?? DateTime.now; + + final SavedHost host; + final SftpRepository _repository; + final UploadManifestRepository _manifest; + final DateTime Function() _now; + + TerminalUploadPhase _phase = TerminalUploadPhase.confirming; + List _items = const []; + String? _destination; + String? _error; + SftpSession? _session; + bool _cancelRequested = false; + bool _disposed = false; + + TerminalUploadPhase get phase => _phase; + List get items => List.unmodifiable(_items); + String? get destination => _destination; + String? get error => _error; + + /// Remote paths of the files that finished uploading. + List get uploadedPaths => [ + for (final item in _items) + if (item.done && item.remotePath != null) item.remotePath!, + ]; + + /// The uploaded paths, individually shell-quoted and space-separated, + /// ready to insert into a terminal command line. + String get quotedPathsForInsertion => + uploadedPaths.map(posixShellQuote).join(' '); + + void prepare(List files) { + _items = [for (final file in files) TerminalUploadItem(file)]; + _phase = TerminalUploadPhase.confirming; + _error = null; + _notify(); + } + + /// Requests cancellation of an in-flight batch. The session is closed so + /// the active transfer aborts promptly instead of draining. + Future cancel() async { + if (_phase != TerminalUploadPhase.uploading && + _phase != TerminalUploadPhase.connecting) { + return; + } + _cancelRequested = true; + final session = _session; + _session = null; + if (session != null) { + try { + await session.close(); + } catch (_) { + // The session may already be broken; cancellation still applies. + } + } + } + + Future uploadAll() async { + if (_phase == TerminalUploadPhase.uploading || + _phase == TerminalUploadPhase.connecting || + _items.isEmpty) { + return; + } + _cancelRequested = false; + _error = null; + _phase = TerminalUploadPhase.connecting; + _notify(); + SftpSession? session; + // Every file written to the server this batch, persisted to the + // manifest in the finally block so partial batches (failure or cancel + // mid-write) never leave unrecorded app-owned files behind. + final newEntries = []; + try { + session = await _repository.connect(host); + _session = session; + if (_cancelRequested) { + _phase = TerminalUploadPhase.cancelled; + _notify(); + return; + } + final home = await session.resolve('.'); + final directory = resolveUploadDirectory( + home: home, + now: _now(), + custom: host.uploadDirectory, + ); + _destination = directory; + await _ensureDirectory(session, directory); + final existing = { + for (final entry in await session.list(directory)) entry.name, + }; + _phase = TerminalUploadPhase.uploading; + _notify(); + + for (final item in _items) { + if (_cancelRequested) { + break; + } + final name = resolveUploadCollision( + sanitizeUploadFileName(item.file.name), + existing, + _now(), + ); + existing.add(name); + final remotePath = '$directory/$name'; + await session.write( + remotePath, + item.file.openRead(), + item.file.size, + onProgress: (bytesSent) { + item.bytesSent = bytesSent; + _notify(); + }, + ); + item.remotePath = remotePath; + item.done = true; + newEntries.add( + UploadManifestEntry(path: remotePath, uploadedAt: _now().toUtc()), + ); + _notify(); + } + + if (_cancelRequested) { + _phase = TerminalUploadPhase.cancelled; + } else { + // Cleanup of expired earlier uploads only runs after a fully + // successful batch; a cancelled or failed batch never deletes. + await _cleanUpExpired(session); + _phase = TerminalUploadPhase.success; + } + _notify(); + } catch (error) { + if (_cancelRequested) { + _phase = TerminalUploadPhase.cancelled; + } else { + _phase = TerminalUploadPhase.failed; + _error = error.toString(); + } + _notify(); + } finally { + // Record everything that reached the server, even after a failure or + // cancellation, so cleanup stays able to manage these files later. + if (newEntries.isNotEmpty) { + try { + await _manifest.setEntries(host.id, [ + ...await _manifest.entriesFor(host.id), + ...newEntries, + ]); + } catch (_) { + // Manifest recording is best-effort; the upload result stands. + } + } + final open = _session; + _session = null; + if (open != null) { + try { + await open.close(); + } catch (_) { + // Closing a torn-down session is best-effort. + } + } + } + } + + /// Creates the target directory (and its parents) if missing. Individual + /// mkdir failures are tolerated — components usually already exist — and + /// the directory is verified afterwards by listing it. + Future _ensureDirectory(SftpSession session, String directory) async { + final segments = directory.split('/').where((s) => s.isNotEmpty).toList(); + var path = ''; + for (final segment in segments) { + path = '$path/$segment'; + try { + await session.makeDirectory(path); + } catch (_) { + // Most likely "already exists"; a real failure surfaces below. + } + } + } + + /// Deletes manifest-listed files older than the host's cleanup cutoff and + /// drops them from the manifest. Only ever touches exact paths this app + /// recorded; never directories. + Future _cleanUpExpired(SftpSession session) async { + final days = host.uploadCleanupDays; + if (days == null || days <= 0) { + return; + } + final entries = await _manifest.entriesFor(host.id); + if (entries.isEmpty) { + return; + } + final cutoff = _now().toUtc().subtract(Duration(days: days)); + final kept = []; + for (final entry in entries) { + if (entry.uploadedAt.isAfter(cutoff)) { + kept.add(entry); + continue; + } + try { + await session.delete( + SftpEntry( + name: entry.path.split('/').last, + path: entry.path, + kind: SftpEntryKind.file, + ), + ); + } catch (_) { + // Already gone or not deletable; either way the entry is dropped + // so a broken file cannot be retried forever. + } + } + await _manifest.setEntries(host.id, kept); + } + + void _notify() { + if (!_disposed) { + notifyListeners(); + } + } + + @override + void dispose() { + _disposed = true; + final open = _session; + _session = null; + if (open != null) { + unawaited(open.close().catchError((_) {})); + } + super.dispose(); + } +} diff --git a/lib/features/terminal/presentation/widgets/terminal_upload_sheet.dart b/lib/features/terminal/presentation/widgets/terminal_upload_sheet.dart new file mode 100644 index 0000000..59add99 --- /dev/null +++ b/lib/features/terminal/presentation/widgets/terminal_upload_sheet.dart @@ -0,0 +1,242 @@ +import 'package:conduit/features/terminal/presentation/terminal_upload_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Formats a byte count for display: `532 B`, `1.4 MB`, `2.1 GB`. +String formatUploadSize(int bytes) { + if (bytes < 1024) { + return '$bytes B'; + } + const units = ['KB', 'MB', 'GB', 'TB']; + var value = bytes.toDouble(); + var unit = -1; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return '${value.toStringAsFixed(value >= 100 ? 0 : 1)} ${units[unit]}'; +} + +/// Shows the confirm → progress → result flow for one upload batch. +/// +/// The sheet drives [controller]; nothing uploads until the user confirms. +/// On success the user can insert the shell-quoted remote paths into the +/// terminal ([onInsert]) or copy them to the clipboard. +Future showTerminalUploadSheet({ + required BuildContext context, + required TerminalUploadController controller, + required ValueChanged onInsert, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + isDismissible: false, + enableDrag: false, + builder: (context) => + TerminalUploadSheet(controller: controller, onInsert: onInsert), + ); +} + +class TerminalUploadSheet extends StatelessWidget { + const TerminalUploadSheet({ + required this.controller, + required this.onInsert, + super.key, + }); + + final TerminalUploadController controller; + final ValueChanged onInsert; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(_title, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + if (controller.destination != null) ...[ + Text( + controller.destination!, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + const SizedBox(height: 8), + ], + Flexible( + child: ListView( + shrinkWrap: true, + children: [ + for (final item in controller.items) + _UploadItemTile(item: item, phase: controller.phase), + ], + ), + ), + if (controller.phase == TerminalUploadPhase.failed) ...[ + const SizedBox(height: 8), + Text( + 'Upload failed. Nothing was inserted; already-uploaded ' + 'files stay on the server.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const SizedBox(height: 12), + _buildActions(context), + ], + ), + ); + }, + ); + } + + String get _title { + return switch (controller.phase) { + TerminalUploadPhase.confirming => 'Upload to server', + TerminalUploadPhase.connecting => 'Connecting…', + TerminalUploadPhase.uploading => 'Uploading…', + TerminalUploadPhase.success => 'Uploaded', + TerminalUploadPhase.failed => 'Upload failed', + TerminalUploadPhase.cancelled => 'Upload cancelled', + }; + } + + Widget _buildActions(BuildContext context) { + switch (controller.phase) { + case TerminalUploadPhase.confirming: + return Row( + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Spacer(), + FilledButton.icon( + onPressed: controller.uploadAll, + icon: const Icon(Icons.upload_rounded), + label: const Text('Upload'), + ), + ], + ); + case TerminalUploadPhase.connecting: + case TerminalUploadPhase.uploading: + return Row( + children: [ + const Spacer(), + TextButton( + onPressed: controller.cancel, + child: const Text('Cancel'), + ), + ], + ); + case TerminalUploadPhase.success: + case TerminalUploadPhase.cancelled: + case TerminalUploadPhase.failed: + final paths = controller.quotedPathsForInsertion; + return Row( + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + const Spacer(), + if (paths.isNotEmpty) ...[ + IconButton( + tooltip: 'Copy paths', + icon: const Icon(Icons.copy_rounded), + onPressed: () { + Clipboard.setData(ClipboardData(text: paths)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Remote paths copied.')), + ); + }, + ), + const SizedBox(width: 6), + FilledButton.icon( + onPressed: () { + onInsert(paths); + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.keyboard_return_rounded), + label: const Text('Insert path'), + ), + ], + ], + ); + } + } +} + +class _UploadItemTile extends StatelessWidget { + const _UploadItemTile({required this.item, required this.phase}); + + final TerminalUploadItem item; + final TerminalUploadPhase phase; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final subtitle = item.done + ? item.remotePath ?? formatUploadSize(item.file.size) + : '${formatUploadSize(item.bytesSent)} of ' + '${formatUploadSize(item.file.size)}'; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Icon( + item.done + ? Icons.check_circle_rounded + : Icons.insert_drive_file_outlined, + size: 18, + color: item.done + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + item.file.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + formatUploadSize(item.file.size), + style: theme.textTheme.bodySmall, + ), + ], + ), + if (phase == TerminalUploadPhase.uploading && !item.done) ...[ + const SizedBox(height: 4), + LinearProgressIndicator(value: item.fraction), + ], + if (item.done || phase != TerminalUploadPhase.confirming) + Padding( + padding: const EdgeInsets.only(left: 26, top: 2), + child: Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index edec549..983bb20 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,8 +16,10 @@ import 'package:conduit/features/local_shell/local_shell_licenses.dart'; import 'package:conduit/features/local_shell/presentation/local_shell_controller.dart'; import 'package:conduit/features/sftp/data/dart_ssh_sftp_repository.dart'; import 'package:conduit/features/sftp/data/file_picker_file_export.dart'; +import 'package:conduit/features/sftp/data/secure_upload_manifest_repository.dart'; import 'package:conduit/features/sftp/domain/file_export.dart'; import 'package:conduit/features/sftp/domain/sftp_repository.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; import 'package:conduit/features/terminal/data/connectivity_plus_network.dart'; import 'package:conduit/features/terminal/data/dart_ssh_terminal_repository.dart'; import 'package:conduit/features/terminal/data/mosh_terminal_repository.dart'; @@ -64,6 +66,9 @@ void main() { ConnectivityPlusNetwork(), ); final sftpRepository = DartSshSftpRepository(hostKeyVerifier); + const uploadManifestRepository = SecureUploadManifestRepository( + secureStorage, + ); final backupService = AppBackupService( hostsController: hostsController, themeController: themeController, @@ -84,6 +89,7 @@ void main() { hostKeyVerifier: hostKeyVerifier, promptCoordinator: promptCoordinator, sftpRepository: sftpRepository, + uploadManifestRepository: uploadManifestRepository, backupService: backupService, fileExport: fileExport, ), @@ -101,6 +107,7 @@ class ConduitApp extends StatefulWidget { required this.hostKeyVerifier, required this.promptCoordinator, required this.sftpRepository, + required this.uploadManifestRepository, required this.backupService, required this.fileExport, super.key, @@ -115,6 +122,7 @@ class ConduitApp extends StatefulWidget { final HostKeyVerifier hostKeyVerifier; final HostKeyPromptCoordinator promptCoordinator; final SftpRepository sftpRepository; + final UploadManifestRepository uploadManifestRepository; final AppBackupService backupService; final FileExport fileExport; @@ -258,6 +266,7 @@ class _ConduitAppState extends State with WidgetsBindingObserver { hostKeyVerifier: widget.hostKeyVerifier, promptCoordinator: widget.promptCoordinator, sftpRepository: widget.sftpRepository, + uploadManifestRepository: widget.uploadManifestRepository, backupService: widget.backupService, fileExport: widget.fileExport, ); diff --git a/pubspec.lock b/pubspec.lock index dd7c52a..13dcdef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -800,7 +800,7 @@ packages: source: hosted version: "1.5.2" wakelock_plus_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: wakelock_plus_platform_interface sha256: b13f99e992e7ae6a152e16c5559d3c07ff445b13330192662494e614ca3e7d7b diff --git a/pubspec.yaml b/pubspec.yaml index ea8c5c3..e458c4b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter_launcher_icons: ^0.14.4 + wakelock_plus_platform_interface: ^1.5.1 flutter_launcher_icons: android: "ic_launcher" diff --git a/test/core/system_navigation_insets_test.dart b/test/core/system_navigation_insets_test.dart index 1e25d2e..54d68ce 100644 --- a/test/core/system_navigation_insets_test.dart +++ b/test/core/system_navigation_insets_test.dart @@ -83,6 +83,7 @@ void main() { hostKeyVerifier: verifier, promptCoordinator: promptCoordinator, sftpRepository: NoNetworkSftpRepository(), + uploadManifestRepository: InMemoryUploadManifest(), backupService: AppBackupService( hostsController: hostsController, themeController: themeController, @@ -130,6 +131,7 @@ void main() { hostKeyVerifier: verifier, promptCoordinator: promptCoordinator, sftpRepository: NoNetworkSftpRepository(), + uploadManifestRepository: InMemoryUploadManifest(), backupService: AppBackupService( hostsController: hostsController, themeController: themeController, @@ -212,6 +214,8 @@ void main() { home: TerminalPage( workspace: workspace, themeController: themeController, + sftpRepository: NoNetworkSftpRepository(), + uploadManifestRepository: InMemoryUploadManifest(), ), ), ); diff --git a/test/core/theme_preferences_repository_test.dart b/test/core/theme_preferences_repository_test.dart index e43a1da..8f0e649 100644 --- a/test/core/theme_preferences_repository_test.dart +++ b/test/core/theme_preferences_repository_test.dart @@ -31,6 +31,7 @@ void main() { TerminalKeyboardAction.control, TerminalKeyboardAction.arrowDown, TerminalKeyboardAction.snippets, + TerminalKeyboardAction.upload, ], ); }, @@ -51,6 +52,7 @@ void main() { items: [ TerminalKeyboardItem.builtIn(TerminalKeyboardAction.escape), TerminalKeyboardItem.builtIn(TerminalKeyboardAction.snippets), + TerminalKeyboardItem.builtIn(TerminalKeyboardAction.upload), ], ), ]); diff --git a/test/features/hosts/hosts_test.dart b/test/features/hosts/hosts_test.dart index ad2eb2c..856b2bb 100644 --- a/test/features/hosts/hosts_test.dart +++ b/test/features/hosts/hosts_test.dart @@ -169,6 +169,8 @@ void main() { ), ], connectSnippetId: 'snippet:deploy', + uploadDirectory: '~/drops', + uploadCleanupDays: 30, lastConnectedAt: DateTime.parse('2025-01-02T03:04:05Z'), ); @@ -198,9 +200,26 @@ void main() { expect(decoded.tmuxStartDirectory, original.tmuxStartDirectory); expect(decoded.snippets, original.snippets); expect(decoded.connectSnippetId, original.connectSnippetId); + expect(decoded.uploadDirectory, original.uploadDirectory); + expect(decoded.uploadCleanupDays, original.uploadCleanupDays); expect(decoded.lastConnectedAt, original.lastConnectedAt); }); + test('missing or corrupt upload settings load as defaults', () { + final decoded = SavedHost.fromJson(const { + 'id': 'id', + 'name': 'n', + 'host': 'h', + 'port': 22, + 'username': 'u', + 'authMethod': 'password', + 'uploadCleanupDays': 'soon', + }); + + expect(decoded.uploadDirectory, ''); + expect(decoded.uploadCleanupDays, isNull); + }); + test('invalid persisted mosh ports fall back to the default', () { final decoded = SavedHost.fromJson(const { 'id': 'id', diff --git a/test/features/sftp/upload_manifest_test.dart b/test/features/sftp/upload_manifest_test.dart new file mode 100644 index 0000000..5c9427d --- /dev/null +++ b/test/features/sftp/upload_manifest_test.dart @@ -0,0 +1,68 @@ +import 'package:conduit/features/sftp/data/secure_upload_manifest_repository.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/test_doubles.dart'; + +void main() { + group('SecureUploadManifestRepository', () { + final entryA = UploadManifestEntry( + path: '/home/u/.conduit/uploads/2026-07-04/a.png', + uploadedAt: DateTime.utc(2026, 7, 4, 10), + ); + final entryB = UploadManifestEntry( + path: '/home/u/.conduit/uploads/2026-07-04/b.pdf', + uploadedAt: DateTime.utc(2026, 7, 4, 11), + ); + + test('round-trips entries per host', () async { + final storage = InMemorySecureStorage(); + final repository = SecureUploadManifestRepository(storage); + + await repository.setEntries('host-1', [entryA]); + await repository.setEntries('host-2', [entryB]); + + expect(await repository.entriesFor('host-1'), [entryA]); + expect(await repository.entriesFor('host-2'), [entryB]); + expect(await repository.entriesFor('host-3'), isEmpty); + }); + + test('drops a host key when its entries empty out', () async { + final storage = InMemorySecureStorage(); + final repository = SecureUploadManifestRepository(storage); + + await repository.setEntries('host-1', [entryA]); + await repository.setEntries('host-1', []); + + expect(await repository.entriesFor('host-1'), isEmpty); + final raw = await storage.read(key: 'conduit.upload_manifest.v1'); + expect(raw, isNot(contains('host-1'))); + }); + + test('loads corrupt payloads as an empty manifest', () async { + final storage = InMemorySecureStorage(); + await storage.write( + key: 'conduit.upload_manifest.v1', + value: 'not-json{{{', + ); + final repository = SecureUploadManifestRepository(storage); + + expect(await repository.entriesFor('host-1'), isEmpty); + }); + + test('skips malformed entries while keeping valid ones', () async { + final storage = InMemorySecureStorage(); + await storage.write( + key: 'conduit.upload_manifest.v1', + value: + '{"host-1":[{"path":"/ok","at":1000},{"path":"","at":2},' + '{"nope":true},42]}', + ); + final repository = SecureUploadManifestRepository(storage); + + final entries = await repository.entriesFor('host-1'); + expect(entries, hasLength(1)); + expect(entries.single.path, '/ok'); + }); + }); +} diff --git a/test/features/sftp/upload_path_test.dart b/test/features/sftp/upload_path_test.dart new file mode 100644 index 0000000..e3003f1 --- /dev/null +++ b/test/features/sftp/upload_path_test.dart @@ -0,0 +1,131 @@ +import 'package:conduit/features/sftp/domain/upload_path.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('resolveUploadDirectory', () { + final now = DateTime(2026, 7, 4); + + test('defaults to a dated folder under ~/.conduit/uploads', () { + expect( + resolveUploadDirectory(home: '/home/user', now: now), + '/home/user/.conduit/uploads/2026-07-04', + ); + }); + + test('trims a trailing slash from home', () { + expect( + resolveUploadDirectory(home: '/home/user/', now: now), + '/home/user/.conduit/uploads/2026-07-04', + ); + }); + + test('accepts absolute, tilde-relative, and home-relative overrides', () { + expect( + resolveUploadDirectory(home: '/h', now: now, custom: '/srv/drop/'), + '/srv/drop', + ); + expect( + resolveUploadDirectory(home: '/h', now: now, custom: '~/incoming'), + '/h/incoming', + ); + expect( + resolveUploadDirectory(home: '/h', now: now, custom: 'incoming'), + '/h/incoming', + ); + expect(resolveUploadDirectory(home: '/h', now: now, custom: '~'), '/h'); + }); + + test('treats blank overrides as the default', () { + expect( + resolveUploadDirectory(home: '/h', now: now, custom: ' '), + '/h/.conduit/uploads/2026-07-04', + ); + }); + }); + + group('sanitizeUploadFileName', () { + test('keeps ordinary names, spaces, and unicode intact', () { + expect( + sanitizeUploadFileName('Screenshot 2026.png'), + 'Screenshot 2026.png', + ); + expect(sanitizeUploadFileName('фото ☕.jpg'), 'фото ☕.jpg'); + }); + + test('strips directory components from path-like names', () { + expect(sanitizeUploadFileName('../../etc/passwd'), 'passwd'); + expect(sanitizeUploadFileName(r'C:\Users\me\doc.pdf'), 'doc.pdf'); + }); + + test('removes control characters', () { + expect(sanitizeUploadFileName('bad\x00name\x1f.txt'), 'badname.txt'); + }); + + test('falls back for empty or dot names', () { + expect(sanitizeUploadFileName(''), 'file'); + expect(sanitizeUploadFileName('..'), 'file'); + expect(sanitizeUploadFileName(' '), 'file'); + }); + }); + + group('resolveUploadCollision', () { + final now = DateTime(2026, 7, 4, 14, 35, 2); + + test('keeps a non-colliding name', () { + expect(resolveUploadCollision('a.jpg', {'b.jpg'}, now), 'a.jpg'); + }); + + test('adds a timestamp before the extension on collision', () { + expect( + resolveUploadCollision('photo.jpg', {'photo.jpg'}, now), + 'photo-143502.jpg', + ); + }); + + test('adds a counter when the timestamped name also collides', () { + expect( + resolveUploadCollision('photo.jpg', { + 'photo.jpg', + 'photo-143502.jpg', + }, now), + 'photo-143502-2.jpg', + ); + }); + + test('handles extensionless and dotfile names', () { + expect(resolveUploadCollision('notes', {'notes'}, now), 'notes-143502'); + expect( + resolveUploadCollision('.envrc', {'.envrc'}, now), + '.envrc-143502', + ); + }); + }); + + group('posixShellQuote', () { + test('passes through unambiguously safe values', () { + expect(posixShellQuote('/home/user/file.txt'), '/home/user/file.txt'); + expect(posixShellQuote('a-b_c.1/d'), 'a-b_c.1/d'); + }); + + test('quotes spaces, globs, and metacharacters', () { + expect(posixShellQuote('my file.txt'), "'my file.txt'"); + expect(posixShellQuote(r'a$(rm -rf).txt'), r"'a$(rm -rf).txt'"); + expect(posixShellQuote('a*b?.txt'), "'a*b?.txt'"); + expect(posixShellQuote('semi;colon'), "'semi;colon'"); + expect(posixShellQuote('(parens).pdf'), "'(parens).pdf'"); + }); + + test('escapes embedded single quotes', () { + expect(posixShellQuote("it's here.txt"), r"'it'\''s here.txt'"); + }); + + test('quotes newlines and leading dashes', () { + expect(posixShellQuote('line\nbreak'), "'line\nbreak'"); + expect(posixShellQuote('-rf'), "'-rf'"); + }); + + test('quotes the empty string', () { + expect(posixShellQuote(''), "''"); + }); + }); +} diff --git a/test/features/terminal/terminal_upload_test.dart b/test/features/terminal/terminal_upload_test.dart new file mode 100644 index 0000000..916b898 --- /dev/null +++ b/test/features/terminal/terminal_upload_test.dart @@ -0,0 +1,449 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:conduit/core/theme/terminal_appearance.dart'; +import 'package:conduit/core/theme/theme_controller.dart'; +import 'package:conduit/features/hosts/domain/saved_host.dart'; +import 'package:conduit/features/sftp/domain/sftp_entry.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; +import 'package:conduit/features/sftp/presentation/sftp_browser_controller.dart' + show SftpUploadFile; +import 'package:conduit/features/terminal/presentation/terminal_page.dart'; +import 'package:conduit/features/terminal/presentation/terminal_upload_controller.dart'; +import 'package:conduit/features/terminal/presentation/terminal_workspace_controller.dart'; +import 'package:conduit/features/terminal/presentation/widgets/terminal_upload_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:wakelock_plus_platform_interface/wakelock_plus_platform_interface.dart'; + +import '../../support/test_doubles.dart'; + +class _NoopWakelock extends WakelockPlusPlatformInterface { + @override + Future toggle({required bool enable}) async {} + + @override + Future get enabled async => false; +} + +SftpEntry _entryNamed(String name) { + return SftpEntry(name: name, path: '/x/$name', kind: SftpEntryKind.file); +} + +SftpUploadFile _file(String name, List bytes) { + return SftpUploadFile( + source: () => Stream.value(Uint8List.fromList(bytes)), + name: name, + size: bytes.length, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + WakelockPlusPlatformInterface.instance = _NoopWakelock(); + + final fixedNow = DateTime(2026, 7, 4, 14, 35, 2); + + group('TerminalUploadController', () { + late FakeSftpSession session; + late InMemoryUploadManifest manifest; + + TerminalUploadController buildController({ + SavedHost? host, + Object? writeError, + }) { + session = FakeSftpSession( + home: '/home/u', + tree: {'/home/u': []}, + writeError: writeError, + ); + manifest = InMemoryUploadManifest(); + return TerminalUploadController( + host: host ?? buildHost('h1'), + repository: FakeSftpRepository(session), + manifest: manifest, + now: () => fixedNow, + ); + } + + test( + 'creates the dated directory chain and uploads with progress', + () async { + final controller = buildController(); + final progress = []; + controller.addListener(() => progress.add(controller.phase)); + controller.prepare([ + _file('photo one.jpg', [1, 2, 3, 4]), + ]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.success); + expect(session.madeDirectories, [ + '/home', + '/home/u', + '/home/u/.conduit', + '/home/u/.conduit/uploads', + '/home/u/.conduit/uploads/2026-07-04', + ]); + const path = '/home/u/.conduit/uploads/2026-07-04/photo one.jpg'; + expect(session.writtenFiles[path], [1, 2, 3, 4]); + expect(controller.uploadedPaths, [path]); + expect( + controller.quotedPathsForInsertion, + "'/home/u/.conduit/uploads/2026-07-04/photo one.jpg'", + ); + expect(manifest.entries['h1'], hasLength(1)); + expect(session.closeCount, 1); + }, + ); + + test('sanitizes hostile names and resolves collisions', () async { + final controller = buildController(); + session.tree['/home/u/.conduit/uploads/2026-07-04'] = [ + _entryNamed('photo.jpg'), + ]; + // Pre-seeding the tree means makeDirectory must not clobber it. + controller.prepare([ + _file('../../photo.jpg', [1]), + _file('photo.jpg', [2]), + ]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.success); + expect(controller.uploadedPaths, [ + '/home/u/.conduit/uploads/2026-07-04/photo-143502.jpg', + '/home/u/.conduit/uploads/2026-07-04/photo-143502-2.jpg', + ]); + }); + + test('uploads to a custom per-host directory', () async { + final controller = buildController( + host: buildHost('h1').copyWith(uploadDirectory: '~/drop zone'), + ); + controller.prepare([ + _file('a.txt', [7]), + ]); + + await controller.uploadAll(); + + expect(controller.uploadedPaths, ['/home/u/drop zone/a.txt']); + expect(controller.quotedPathsForInsertion, "'/home/u/drop zone/a.txt'"); + }); + + test( + 'reports failure and keeps state recoverable on write errors', + () async { + final controller = buildController(writeError: StateError('disk full')); + controller.prepare([ + _file('a.txt', [1]), + ]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.failed); + expect(controller.error, contains('disk full')); + expect(controller.uploadedPaths, isEmpty); + expect(manifest.entries, isEmpty); + expect(session.closeCount, 1); + }, + ); + + test('records already-uploaded files in the manifest when a later file ' + 'fails', () async { + session = FakeSftpSession( + home: '/home/u', + tree: {'/home/u': []}, + failWriteAtIndex: 1, + ); + manifest = InMemoryUploadManifest(); + final controller = TerminalUploadController( + host: buildHost('h1'), + repository: FakeSftpRepository(session), + manifest: manifest, + now: () => fixedNow, + ); + controller.prepare([ + _file('first.txt', [1]), + _file('second.txt', [2]), + ]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.failed); + // The first file reached the server, so cleanup must know about it. + expect(manifest.entries['h1']!.map((e) => e.path), [ + '/home/u/.conduit/uploads/2026-07-04/first.txt', + ]); + // A failed batch never deletes anything. + expect(session.deletedPaths, isEmpty); + }); + + test('a cancelled batch records uploads but never deletes', () async { + final controller = buildController( + host: buildHost('h1').copyWith(uploadCleanupDays: 7), + ); + manifest.entries['h1'] = [ + UploadManifestEntry( + path: '/old/expired.png', + uploadedAt: DateTime.utc(2020), + ), + ]; + controller.prepare([ + _file('a.txt', [1]), + _file('b.txt', [2]), + ]); + controller.addListener(() { + if (controller.items.first.done && + controller.phase == TerminalUploadPhase.uploading) { + controller.cancel(); + } + }); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.cancelled); + expect(session.deletedPaths, isEmpty); + expect( + manifest.entries['h1']!.map((e) => e.path), + containsAll([ + '/old/expired.png', + '/home/u/.conduit/uploads/2026-07-04/a.txt', + ]), + ); + }); + + test('fails cleanly when the connection cannot be opened', () async { + final controller = TerminalUploadController( + host: buildHost('h1'), + repository: ThrowingSftpRepository(), + manifest: InMemoryUploadManifest(), + now: () => fixedNow, + ); + controller.prepare([ + _file('a.txt', [1]), + ]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.failed); + expect(controller.error, isNotNull); + }); + + test( + 'cancelling between files stops the batch and closes the session', + () async { + final controller = buildController(); + controller.prepare([ + _file('a.txt', [1]), + _file('b.txt', [2]), + ]); + controller.addListener(() { + if (controller.items.first.done && + controller.phase == TerminalUploadPhase.uploading) { + controller.cancel(); + } + }); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.cancelled); + expect(controller.uploadedPaths, hasLength(1)); + expect(session.writtenFiles.keys, hasLength(1)); + expect(session.closeCount, greaterThanOrEqualTo(1)); + }, + ); + + test('zero-byte files upload successfully', () async { + final controller = buildController(); + controller.prepare([_file('empty.txt', [])]); + + await controller.uploadAll(); + + expect(controller.phase, TerminalUploadPhase.success); + expect( + session.writtenFiles['/home/u/.conduit/uploads/2026-07-04/empty.txt'], + isEmpty, + ); + }); + + test( + 'cleanup deletes only manifest-listed files past the cutoff', + () async { + final controller = buildController( + host: buildHost('h1').copyWith(uploadCleanupDays: 7), + ); + manifest.entries['h1'] = [ + UploadManifestEntry( + path: '/home/u/.conduit/uploads/2026-06-01/old.png', + uploadedAt: DateTime.utc(2026, 6), + ), + UploadManifestEntry( + path: '/home/u/.conduit/uploads/2026-07-03/fresh.png', + uploadedAt: DateTime.utc(2026, 7, 3), + ), + ]; + controller.prepare([ + _file('new.txt', [1]), + ]); + + await controller.uploadAll(); + + expect(session.deletedPaths, [ + '/home/u/.conduit/uploads/2026-06-01/old.png', + ]); + final kept = manifest.entries['h1']!.map((e) => e.path).toList(); + expect( + kept, + isNot(contains('/home/u/.conduit/uploads/2026-06-01/old.png')), + ); + expect(kept, contains('/home/u/.conduit/uploads/2026-07-03/fresh.png')); + expect(kept, contains('/home/u/.conduit/uploads/2026-07-04/new.txt')); + }, + ); + + test('never deletes when cleanup is disabled', () async { + final controller = buildController(); + manifest.entries['h1'] = [ + UploadManifestEntry( + path: '/somewhere/ancient.png', + uploadedAt: DateTime.utc(2020), + ), + ]; + controller.prepare([ + _file('new.txt', [1]), + ]); + + await controller.uploadAll(); + + expect(session.deletedPaths, isEmpty); + expect( + manifest.entries['h1']!.map((e) => e.path), + contains('/somewhere/ancient.png'), + ); + }); + }); + + group('Terminal upload flow', () { + testWidgets('picker → confirm → progress → insert quoted path', ( + tester, + ) async { + final terminalSession = TrackableTerminalSession(); + final workspace = TerminalWorkspaceController( + ImmediateTerminalRepository(terminalSession), + ); + final themeController = ThemeController(InMemoryThemePreferences()); + final sftpSession = FakeSftpSession( + home: '/home/u', + tree: {'/home/u': []}, + ); + final controller = workspace.open(buildHost('h1')); + await tester.runAsync(controller.connect); + addTearDown(workspace.dispose); + + await tester.pumpWidget( + MaterialApp( + home: TerminalPage( + workspace: workspace, + themeController: themeController, + sftpRepository: FakeSftpRepository(sftpSession), + uploadManifestRepository: InMemoryUploadManifest(), + uploadFilePicker: () async => [ + _file('my shot.png', [9, 9]), + ], + ), + ), + ); + await tester.pump(); + await themeController.setTerminalKeyboardRows(const [ + TerminalKeyboardRow( + items: [TerminalKeyboardItem.builtIn(TerminalKeyboardAction.upload)], + ), + ]); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.upload_file_rounded)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + // Confirmation state: filename, size, and explicit Upload action. + expect(find.byType(TerminalUploadSheet), findsOneWidget); + expect(find.text('my shot.png'), findsOneWidget); + expect(find.text('2 B'), findsOneWidget); + expect(sftpSession.writtenFiles, isEmpty); + + await tester.tap(find.text('Upload')); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Uploaded'), findsOneWidget); + expect(sftpSession.writtenFiles.keys.single, endsWith('/my shot.png')); + + await tester.tap(find.text('Insert path')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.byType(TerminalUploadSheet), findsNothing); + final sent = terminalSession.sent + .map((bytes) => utf8.decode(bytes)) + .join(); + expect(sent, contains("'")); + expect(sent, contains('/my shot.png')); + // Inserted, not executed: no Enter was sent. + expect(sent, isNot(contains('\r'))); + + // Flush the terminal resize debounce timer. + await tester.pump(const Duration(milliseconds: 300)); + }); + + testWidgets('failed uploads surface an error state', (tester) async { + final workspace = TerminalWorkspaceController( + ImmediateTerminalRepository(TrackableTerminalSession()), + ); + final themeController = ThemeController(InMemoryThemePreferences()); + final controller = workspace.open(buildHost('h1')); + await tester.runAsync(controller.connect); + addTearDown(workspace.dispose); + + await tester.pumpWidget( + MaterialApp( + home: TerminalPage( + workspace: workspace, + themeController: themeController, + sftpRepository: ThrowingSftpRepository(), + uploadManifestRepository: InMemoryUploadManifest(), + uploadFilePicker: () async => [ + _file('a.txt', [1]), + ], + ), + ), + ); + await tester.pump(); + await themeController.setTerminalKeyboardRows(const [ + TerminalKeyboardRow( + items: [TerminalKeyboardItem.builtIn(TerminalKeyboardAction.upload)], + ), + ]); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.upload_file_rounded)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + await tester.tap(find.text('Upload')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('Upload failed'), findsOneWidget); + expect(find.textContaining('Nothing was inserted'), findsOneWidget); + + await tester.tap(find.text('Close')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 350)); + expect(find.byType(TerminalUploadSheet), findsNothing); + + await tester.pump(const Duration(milliseconds: 300)); + }); + }); +} diff --git a/test/support/test_doubles.dart b/test/support/test_doubles.dart index 1315a25..67b4259 100644 --- a/test/support/test_doubles.dart +++ b/test/support/test_doubles.dart @@ -11,6 +11,7 @@ import 'package:conduit/features/sftp/domain/file_export.dart'; import 'package:conduit/features/sftp/domain/sftp_entry.dart'; import 'package:conduit/features/sftp/domain/sftp_repository.dart'; import 'package:conduit/features/sftp/domain/sftp_session.dart'; +import 'package:conduit/features/sftp/domain/upload_manifest.dart'; import 'package:conduit/features/terminal/domain/host_key_prompt.dart'; import 'package:conduit/features/terminal/domain/host_key_verifier.dart'; import 'package:conduit/features/terminal/domain/network_connectivity.dart'; @@ -426,6 +427,26 @@ class NoopVerifier implements HostKeyVerifier { }) async => false; } +class InMemoryUploadManifest implements UploadManifestRepository { + final Map> entries = {}; + + @override + Future> entriesFor(String hostId) async => + List.of(entries[hostId] ?? const []); + + @override + Future setEntries( + String hostId, + List newEntries, + ) async { + if (newEntries.isEmpty) { + entries.remove(hostId); + } else { + entries[hostId] = List.of(newEntries); + } + } +} + class NoNetworkSftpRepository implements SftpRepository { @override Future connect(SavedHost host) { @@ -448,13 +469,27 @@ class ThrowingSftpRepository implements SftpRepository { } class FakeSftpSession implements SftpSession { - FakeSftpSession({required this.home, required this.tree}); + FakeSftpSession({ + required this.home, + required this.tree, + this.writeError, + this.failWriteAtIndex, + }); final String home; final Map> tree; final List madeDirectories = []; final Map> writtenFiles = {}; final Map listCalls = {}; + final List deletedPaths = []; + int closeCount = 0; + + /// When set, every write throws this error after draining nothing. + final Object? writeError; + + /// When set, only the write with this zero-based index throws. + final int? failWriteAtIndex; + int _writeCount = 0; @override Future> list(String path) async { @@ -486,6 +521,12 @@ class FakeSftpSession implements SftpSession { int length, { void Function(int bytesSent)? onProgress, }) async { + final index = _writeCount; + _writeCount += 1; + final error = writeError; + if (error != null || index == failWriteAtIndex) { + throw error ?? StateError('write $index failed'); + } final bytes = []; await for (final chunk in data) { bytes.addAll(chunk); @@ -497,17 +538,21 @@ class FakeSftpSession implements SftpSession { @override Future makeDirectory(String path) async { madeDirectories.add(path); - tree[path] = []; + tree.putIfAbsent(path, () => []); } @override Future rename(String from, String to) async {} @override - Future delete(SftpEntry entry) async {} + Future delete(SftpEntry entry) async { + deletedPaths.add(entry.path); + } @override - Future close() async {} + Future close() async { + closeCount += 1; + } } class RecordingFileExport implements FileExport {