diff --git a/packages/sshnoports/bin/activate_cli.dart b/packages/sshnoports/bin/activate_cli.dart index ba1ea6718..9baf29390 100644 --- a/packages/sshnoports/bin/activate_cli.dart +++ b/packages/sshnoports/bin/activate_cli.dart @@ -7,7 +7,7 @@ Future main(List args) async { try { await activate_cli.main(args); } catch (e) { - print(e.toString()); + stdout.writeln(e.toString()); } exit(0); } diff --git a/packages/sshnoports/bin/sshnp.dart b/packages/sshnoports/bin/sshnp.dart index a25232e29..32094ff96 100644 --- a/packages/sshnoports/bin/sshnp.dart +++ b/packages/sshnoports/bin/sshnp.dart @@ -35,20 +35,18 @@ void main(List args) async { await runZonedGuarded(() async { if (params.listDevices) { - print('Searching for devices...'); + stdout.writeln('Searching for devices...'); var (active, off, info) = await sshnp.listDevices(); if (active.isEmpty && off.isEmpty) { - print('[X] No devices found\n'); - print( - 'Note: only devices with sshnpd version 3.4.0 or higher are supported by this command.'); - print( - 'Please update your devices to sshnpd version >= 3.4.0 and try again.'); + stdout.writeln('[X] No devices found\n'); + stdout.writeln('Note: only devices with sshnpd version 3.4.0 or higher are supported by this command.'); + stdout.writeln('Please update your devices to sshnpd version >= 3.4.0 and try again.'); exit(0); } - print('Active Devices:'); + stdout.writeln('Active Devices:'); _printDevices(active, info); - print('Inactive Devices:'); + stdout.writeln('Inactive Devices:'); _printDevices(off, info); exit(0); } @@ -80,10 +78,10 @@ void main(List args) async { void _printDevices(Iterable devices, Map info) { if (devices.isEmpty) { - print(' [X] No devices found'); + stdout.writeln(' [X] No devices found'); return; } for (var device in devices) { - print(' $device - v${info[device]?['version']}'); + stdout.writeln(' $device - v${info[device]?['version']}'); } } diff --git a/packages/sshnoports/bin/sshrv.dart b/packages/sshnoports/bin/sshrv.dart index 4433afe68..f4baa101a 100644 --- a/packages/sshnoports/bin/sshrv.dart +++ b/packages/sshnoports/bin/sshrv.dart @@ -4,7 +4,7 @@ import 'package:sshnoports/sshrv/sshrv.dart'; Future main(List args) async { if (args.length < 2 || args.length > 3) { - print('sshrv [localhost sshd port, defaults to 22]'); + stdout.writeln('sshrv [localhost sshd port, defaults to 22]'); exit(-1); } diff --git a/packages/sshnoports/lib/common/create_at_client_cli.dart b/packages/sshnoports/lib/common/create_at_client_cli.dart index eb749553a..3ac5df538 100644 --- a/packages/sshnoports/lib/common/create_at_client_cli.dart +++ b/packages/sshnoports/lib/common/create_at_client_cli.dart @@ -2,7 +2,7 @@ import 'dart:io'; import 'package:at_client/at_client.dart'; import 'package:at_onboarding_cli/at_onboarding_cli.dart'; import 'package:version/version.dart'; - +import 'package:path/path.dart' as path; import 'service_factories.dart'; Future createAtClientCli({ @@ -21,22 +21,18 @@ Future createAtClientCli({ pathBase += '$pathExtension${Platform.pathSeparator}'; } AtOnboardingPreference atOnboardingConfig = AtOnboardingPreference() - ..hiveStoragePath = - '$pathBase/storage'.replaceAll('/', Platform.pathSeparator) + ..hiveStoragePath = path.normalize('$pathBase/storage') ..namespace = namespace - ..downloadPath = '$homeDirectory/$subDirectory/files' - .replaceAll('/', Platform.pathSeparator) + ..downloadPath = path.normalize('$homeDirectory/$subDirectory/files') ..isLocalStoreRequired = true - ..commitLogPath = - '$pathBase/storage/commitLog'.replaceAll('/', Platform.pathSeparator) + ..commitLogPath = path.normalize('$pathBase/storage/commitLog') ..fetchOfflineNotifications = false ..atKeysFilePath = atKeysFilePath ..atProtocolEmitted = Version(2, 0, 0) ..rootDomain = rootDomain; - AtOnboardingService onboardingService = AtOnboardingServiceImpl( - atsign, atOnboardingConfig, - atServiceFactory: ServiceFactoryWithNoOpSyncService()); + AtOnboardingService onboardingService = + AtOnboardingServiceImpl(atsign, atOnboardingConfig, atServiceFactory: ServiceFactoryWithNoOpSyncService()); await onboardingService.authenticate(); diff --git a/packages/sshnoports/lib/common/utils.dart b/packages/sshnoports/lib/common/utils.dart index 3ccd15b61..ac8c36a76 100644 --- a/packages/sshnoports/lib/common/utils.dart +++ b/packages/sshnoports/lib/common/utils.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:at_chops/at_chops.dart'; import 'package:at_client/at_client.dart'; import 'package:at_utils/at_utils.dart'; +import 'package:path/path.dart' as path; /// Get the home directory or null if unknown. String? getHomeDirectory({bool throwIfNull = false}) { @@ -56,20 +57,19 @@ bool checkNonAscii(String test) { String getDefaultAtKeysFilePath(String homeDirectory, String? atSign) { if (atSign == null) return ''; - return '$homeDirectory/.atsign/keys/${atSign}_key.atKeys' - .replaceAll('/', Platform.pathSeparator); + return path.normalize('$homeDirectory/.atsign/keys/${atSign}_key.atKeys'); } String getDefaultSshDirectory(String homeDirectory) { - return '$homeDirectory/.ssh/'.replaceAll('/', Platform.pathSeparator); + return path.normalize('$homeDirectory/.ssh/'); } String getDefaultSshnpDirectory(String homeDirectory) { - return '$homeDirectory/.sshnp/'.replaceAll('/', Platform.pathSeparator); + return path.normalize('$homeDirectory/.sshnp/'); } String getDefaultSshnpConfigDirectory(String homeDirectory) { - return '$homeDirectory/.sshnp/config'.replaceAll('/', Platform.pathSeparator); + return path.normalize('$homeDirectory/.sshnp/config'); } /// Checks if the provided atSign's atServer has been properly activated with a public RSA key. @@ -144,9 +144,9 @@ Future<(String, String)> generateSshKeys( } String sshPublicKey = - await File('$sshHomeDirectory${sessionId}_sshnp.pub').readAsString(); + await File('$sshHomeDirectory/${sessionId}_sshnp.pub').readAsString(); String sshPrivateKey = - await File('$sshHomeDirectory${sessionId}_sshnp').readAsString(); + await File('$sshHomeDirectory/${sessionId}_sshnp').readAsString(); return (sshPublicKey, sshPrivateKey); } @@ -162,16 +162,15 @@ Future addEphemeralKeyToAuthorizedKeys( } String homeDirectory = getHomeDirectory(throwIfNull: true)!; + var sshHomeDirectory = getDefaultSshDirectory(homeDirectory); - var sshHomeDirectory = - '$homeDirectory/.ssh/'.replaceAll('/', Platform.pathSeparator); if (!Directory(sshHomeDirectory).existsSync()) { Directory(sshHomeDirectory).createSync(); } // Check to see if the ssh Publickey is already in the authorized_keys file. // If not, then append it. - var authKeys = File('${sshHomeDirectory}authorized_keys'); + var authKeys = File(path.normalize('$sshHomeDirectory/authorized_keys')); var authKeysContent = await authKeys.readAsString(); if (!authKeysContent.endsWith('\n')) { @@ -184,14 +183,16 @@ Future addEphemeralKeyToAuthorizedKeys( } // Set up a safe authorized_keys file, for the ssh tunnel await authKeys.writeAsString( - 'command="echo \\"ssh session complete\\";sleep 20"' - ',PermitOpen="localhost:$localSshdPort"' - '$permissions' - ' ' - '${sshPublicKey.trim()}' - ' ' - 'sshnp_ephemeral_$sessionId\n', - mode: FileMode.append); + 'command="echo \\"ssh session complete\\";sleep 20"' + ',PermitOpen="localhost:$localSshdPort"' + '$permissions' + ' ' + '${sshPublicKey.trim()}' + ' ' + 'sshnp_ephemeral_$sessionId\n', + mode: FileMode.append, + flush: true, + ); } } @@ -201,7 +202,7 @@ Future removeEphemeralKeyFromAuthorizedKeys( try { sshHomeDirectory ??= getDefaultSshDirectory(getHomeDirectory(throwIfNull: true)!); - final File file = File('${sshHomeDirectory}authorized_keys'); + final File file = File(path.normalize('$sshHomeDirectory/authorized_keys')); logger.info('Removing ephemeral key for session $sessionId' ' from ${file.absolute.path}'); // read into List of strings @@ -212,7 +213,8 @@ Future removeEphemeralKeyFromAuthorizedKeys( await file.writeAsString(lines.join('\n')); await file.writeAsString('\n', mode: FileMode.writeOnlyAppend); } catch (e) { - logger.severe('Unable to tidy up ${sshHomeDirectory}authorized_keys'); + logger.severe( + 'Unable to tidy up ${path.normalize('$sshHomeDirectory/authorized_keys')}'); } } @@ -289,9 +291,8 @@ Future _fetchFromLocalPKCache( AtClient atClient, String atSign, bool useFileStorage) async { String dontAtMe = atSign.substring(1); if (useFileStorage) { - String fn = '${getHomeDirectory(throwIfNull: true)}' - '/.atsign/sshnp/cached_pks/$dontAtMe' - .replaceAll('/', Platform.pathSeparator); + String fn = path.normalize( + '${getHomeDirectory(throwIfNull: true)}/.atsign/sshnp/cached_pks/$dontAtMe'); File f = File(fn); if (await f.exists()) { return (await f.readAsString()).trim(); @@ -314,11 +315,9 @@ Future _storeToLocalPKCache( String pk, AtClient atClient, String atSign, bool useFileStorage) async { String dontAtMe = atSign.substring(1); if (useFileStorage) { - String dirName = - '${getHomeDirectory(throwIfNull: true)}/.atsign/sshnp/cached_pks' - .replaceAll('/', Platform.pathSeparator); - String fileName = - '$dirName/$dontAtMe'.replaceAll('/', Platform.pathSeparator); + String dirName = path.normalize( + '${getHomeDirectory(throwIfNull: true)}/.atsign/sshnp/cached_pks'); + String fileName = path.normalize('$dirName/$dontAtMe'); File f = File(fileName); if (!await f.exists()) { diff --git a/packages/sshnoports/lib/sshnp/config_repository/config_file_repository.dart b/packages/sshnoports/lib/sshnp/config_repository/config_file_repository.dart new file mode 100644 index 000000000..1a9f3aa3d --- /dev/null +++ b/packages/sshnoports/lib/sshnp/config_repository/config_file_repository.dart @@ -0,0 +1,152 @@ +import 'dart:io'; + +import 'package:sshnoports/common/utils.dart'; +import 'package:sshnoports/sshnp/sshnp.dart'; +import 'package:sshnoports/sshnp/sshnp_arg.dart'; +import 'package:path/path.dart' as path; + +class ConfigFileRepository { + static String toProfileName(String fileName, {bool replaceSpaces = true}) { + var profileName = path.basenameWithoutExtension(fileName); + if (replaceSpaces) profileName = profileName.replaceAll('_', ' '); + return profileName; + } + + static String fromProfileName(String profileName, + {String? directory, bool replaceSpaces = true, bool basenameOnly = false}) { + var fileName = profileName; + if (replaceSpaces) fileName = fileName.replaceAll(' ', '_'); + final basename = '$fileName.env'; + if (basenameOnly) return basename; + return path.join( + directory ?? getDefaultSshnpConfigDirectory(getHomeDirectory(throwIfNull: true)!), + basename, + ); + } + + static Future createConfigDirectory({String? directory}) async { + directory ??= getDefaultSshnpConfigDirectory(getHomeDirectory(throwIfNull: true)!); + var dir = Directory(directory); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + static Future> listProfiles({String? directory}) async { + var profileNames = {}; + + var homeDirectory = getHomeDirectory(throwIfNull: true)!; + directory ??= getDefaultSshnpConfigDirectory(homeDirectory); + var files = Directory(directory).list(); + + await files.forEach((file) { + if (file is! File) return; + if (path.extension(file.path) != '.env') return; + if (path.basenameWithoutExtension(file.path).isEmpty) return; // ignore '.env' file - empty profileName + profileNames.add(toProfileName(file.path)); + }); + return profileNames; + } + + static Future getParams(String profileName, {String? directory}) async { + var fileName = fromProfileName(profileName, directory: directory); + return SSHNPParams.fromFile(fileName); + } + + static Future putParams(SSHNPParams params, {String? directory, bool overwrite = false}) async { + if (params.profileName == null || params.profileName!.isEmpty) { + throw Exception('profileName is null or empty'); + } + + var fileName = fromProfileName(params.profileName!, directory: directory); + var file = File(fileName); + + var exists = await file.exists(); + + if (exists && !overwrite) { + throw Exception('Failed to write config file: ${file.path} already exists'); + } + + // FileMode.write will create the file if it does not exist + // and overwrite existing files if it does exist + return file.writeAsString(params.toConfig(), mode: FileMode.write); + } + + static Future deleteParams(SSHNPParams params, {String? directory}) async { + if (params.profileName == null || params.profileName!.isEmpty) { + throw Exception('profileName is null or empty'); + } + + var fileName = fromProfileName(params.profileName!, directory: directory); + var file = File(fileName); + + var exists = await file.exists(); + + if (!exists) { + throw Exception('Cannot delete ${file.path}, file does not exist'); + } + + return file.delete(); + } + + static Map parseConfigFile(String fileName) { + File file = File(fileName); + + if (!file.existsSync()) { + throw Exception('Config file does not exist: $fileName'); + } + try { + List lines = file.readAsLinesSync(); + return parseConfigFileContents(lines); + } on FileSystemException { + throw Exception('Error reading config file: $fileName'); + } + } + + static Map parseConfigFileContents(List lines) { + Map args = {}; + + try { + for (String line in lines) { + if (line.startsWith('#')) continue; + + var parts = line.split('='); + if (parts.length != 2) continue; + + var key = parts[0].trim(); + var value = parts[1].trim(); + + SSHNPArg arg = SSHNPArg.fromBashName(key); + if (arg.name.isEmpty) continue; + + switch (arg.format) { + case ArgFormat.flag: + if (value.toLowerCase() == 'true') { + args[arg.name] = true; + } + continue; + case ArgFormat.multiOption: + var values = value.split(','); + args.putIfAbsent(arg.name, () => []); + for (String val in values) { + if (val.isEmpty) continue; + args[arg.name].add(val); + } + continue; + case ArgFormat.option: + if (value.isEmpty) continue; + if (arg.type == ArgType.integer) { + args[arg.name] = int.tryParse(value); + } else { + args[arg.name] = value; + } + continue; + } + } + return args; + } catch (e) { + throw Exception('Error parsing config file'); + } + } +} diff --git a/packages/sshnoports/lib/sshnp/config_repository/config_key_repository.dart b/packages/sshnoports/lib/sshnp/config_repository/config_key_repository.dart new file mode 100644 index 000000000..ba52b0066 --- /dev/null +++ b/packages/sshnoports/lib/sshnp/config_repository/config_key_repository.dart @@ -0,0 +1,51 @@ +import 'package:at_client/at_client.dart'; +import 'package:sshnoports/sshnp/sshnp.dart'; +import 'package:sshnoports/sshnpd/sshnpd.dart'; + +class ConfigKeyRepository { + static const String _keyPrefix = 'profile_'; + static const String _configNamespace = 'profiles.${SSHNPD.namespace}'; + + static String toProfileName(AtKey atKey, {bool replaceSpaces = true}) { + var profileName = atKey.key!.split('.').first; + profileName = profileName.replaceFirst(_keyPrefix, ''); + if (replaceSpaces) profileName = profileName.replaceAll('_', ' '); + return profileName; + } + + static AtKey fromProfileName(String profileName, {String sharedBy = '', bool replaceSpaces = true}) { + if (replaceSpaces) profileName = profileName.replaceAll(' ', '_'); + return AtKey.self( + '$_keyPrefix$profileName', + namespace: _configNamespace, + sharedBy: sharedBy, + ).build(); + } + + static Future> listProfiles(AtClient atClient) async { + var keys = await atClient.getAtKeys(regex: _configNamespace); + return keys.map((e) => toProfileName(e)); + } + + static Future getParams(String profileName, + {required AtClient atClient, GetRequestOptions? options}) async { + AtKey key = fromProfileName(profileName); + key.sharedBy = atClient.getCurrentAtSign()!; + AtValue value = await atClient.get(key, getRequestOptions: options); + if (value.value == null) return SSHNPParams.empty(); + return SSHNPParams.fromJson(value.value!); + } + + static Future putParams(SSHNPParams params, {required AtClient atClient, PutRequestOptions? options}) async { + AtKey key = fromProfileName(params.profileName!); + key.sharedBy = atClient.getCurrentAtSign()!; + await atClient.put(key, params.toJson(), putRequestOptions: options); + } + + static Future deleteParams(String profileName, + {required AtClient atClient, DeleteRequestOptions? options}) async { + AtKey key = fromProfileName(profileName); + key.sharedBy = atClient.getCurrentAtSign()!; + await atClient.delete(key, deleteRequestOptions: options); + } +} diff --git a/packages/sshnoports/lib/sshnp/sshnp.dart b/packages/sshnoports/lib/sshnp/sshnp.dart index bc7b43cb4..5ea305fc7 100644 --- a/packages/sshnoports/lib/sshnp/sshnp.dart +++ b/packages/sshnoports/lib/sshnp/sshnp.dart @@ -14,6 +14,7 @@ import 'package:path/path.dart' as path; import 'package:sshnoports/common/create_at_client_cli.dart'; import 'package:sshnoports/common/supported_ssh_clients.dart'; import 'package:sshnoports/common/utils.dart'; +import 'package:sshnoports/sshnp/config_repository/config_file_repository.dart'; import 'package:sshnoports/sshnp/sshnp_arg.dart'; import 'package:sshnoports/sshnp/utils.dart'; import 'package:sshnoports/sshnpd/sshnpd.dart'; @@ -268,6 +269,5 @@ abstract class SSHNP { /// Returns two Iterable: /// - Iterable of atSigns of sshnpd that responded /// - Iterable of atSigns of sshnpd that did not respond - Future<(Iterable, Iterable, Map)> - listDevices(); + Future<(Iterable, Iterable, Map)> listDevices(); } diff --git a/packages/sshnoports/lib/sshnp/sshnp_arg.dart b/packages/sshnoports/lib/sshnp/sshnp_arg.dart index 6d225117d..e9ca42ea2 100644 --- a/packages/sshnoports/lib/sshnp/sshnp_arg.dart +++ b/packages/sshnoports/lib/sshnp/sshnp_arg.dart @@ -1,3 +1,4 @@ +import 'package:args/args.dart'; import 'package:sshnoports/common/supported_ssh_clients.dart'; import 'package:sshnoports/common/defaults.dart'; @@ -24,6 +25,9 @@ class SSHNPArg { final dynamic defaultsTo; final ArgType type; final Iterable? allowed; + final bool commandLineOnly; + final List? aliases; + final bool negatable; const SSHNPArg({ required this.name, @@ -34,6 +38,9 @@ class SSHNPArg { this.defaultsTo, this.type = ArgType.string, this.allowed, + this.commandLineOnly = false, + this.aliases, + this.negatable = true, }); String get bashName => name.replaceAll('-', '_').toUpperCase(); @@ -57,90 +64,88 @@ class SSHNPArg { } static List args = [ - SSHNPArg( + const SSHNPArg( name: 'key-file', abbr: 'k', help: 'Sending atSign\'s atKeys file if not in ~/.atsign/keys/', ), - SSHNPArg( + const SSHNPArg( name: 'from', abbr: 'f', help: 'Sending (a.k.a. client) atSign', mandatory: true, ), - SSHNPArg( + const SSHNPArg( name: 'to', abbr: 't', help: 'Receiving device atSign', mandatory: true, ), - SSHNPArg( + const SSHNPArg( name: 'device', abbr: 'd', help: 'Receiving device name', defaultsTo: SSHNP.defaultDevice, ), - SSHNPArg( + const SSHNPArg( name: 'host', abbr: 'h', help: 'atSign of sshrvd daemon or FQDN/IP address to connect back to', mandatory: true, ), - SSHNPArg( + const SSHNPArg( name: 'port', abbr: 'p', - help: - 'TCP port to connect back to (only required if --host specified a FQDN/IP)', + help: 'TCP port to connect back to (only required if --host specified a FQDN/IP)', defaultsTo: SSHNP.defaultPort, type: ArgType.integer, ), - SSHNPArg( - name: 'local-port', - abbr: 'l', - help: - 'Reverse ssh port to listen on, on your local machine, by sshnp default finds a spare port', - defaultsTo: SSHNP.defaultLocalPort, - type: ArgType.integer), - SSHNPArg( + const SSHNPArg( + name: 'local-port', + abbr: 'l', + help: 'Reverse ssh port to listen on, on your local machine, by sshnp default finds a spare port', + defaultsTo: SSHNP.defaultLocalPort, + type: ArgType.integer, + ), + const SSHNPArg( name: 'ssh-public-key', abbr: 's', - help: - 'Public key file from ~/.ssh to be appended to authorized_hosts on the remote device', + help: 'Public key file from ~/.ssh to be appended to authorized_hosts on the remote device', defaultsTo: SSHNP.defaultSendSshPublicKey, ), - SSHNPArg( + const SSHNPArg( name: 'local-ssh-options', abbr: 'o', help: 'Add these commands to the local ssh command', format: ArgFormat.multiOption, ), - SSHNPArg( + const SSHNPArg( name: 'verbose', abbr: 'v', defaultsTo: defaultVerbose, help: 'More logging', format: ArgFormat.flag, ), - SSHNPArg( + const SSHNPArg( name: 'rsa', abbr: 'r', defaultsTo: defaultRsa, help: 'Use RSA 4096 keys rather than the default ED25519 keys', format: ArgFormat.flag, ), - SSHNPArg( + const SSHNPArg( name: 'remote-user-name', abbr: 'u', help: 'username to use in the ssh session on the remote host', ), - SSHNPArg( + const SSHNPArg( name: 'root-domain', help: 'atDirectory domain', defaultsTo: defaultRootDomain, mandatory: false, format: ArgFormat.option, ), - SSHNPArg( + const SSHNPArg( name: 'local-sshd-port', help: 'port on which sshd is listening locally on the client host', defaultsTo: defaultLocalSshdPort, @@ -149,13 +154,13 @@ class SSHNPArg { format: ArgFormat.option, type: ArgType.integer, ), - SSHNPArg( + const SSHNPArg( name: 'legacy-daemon', defaultsTo: SSHNP.defaultLegacyDaemon, - help: 'Request is to a legacy (< 3.5.0) noports daemon', + help: 'Request is to a legacy (< 4.0.0) noports daemon', format: ArgFormat.flag, ), - SSHNPArg( + const SSHNPArg( name: 'remote-sshd-port', help: 'port on which sshd is listening locally on the device host', defaultsTo: defaultRemoteSshdPort, @@ -163,14 +168,14 @@ class SSHNPArg { format: ArgFormat.option, type: ArgType.integer, ), - SSHNPArg( + const SSHNPArg( name: 'idle-timeout', - help: - 'number of seconds after which inactive ssh connections will be closed', + help: 'number of seconds after which inactive ssh connections will be closed', defaultsTo: defaultIdleTimeout, mandatory: false, format: ArgFormat.option, type: ArgType.integer, + commandLineOnly: true, ), SSHNPArg( name: 'ssh-client', @@ -180,13 +185,27 @@ class SSHNPArg { format: ArgFormat.option, type: ArgType.string, allowed: SupportedSshClient.values.map((c) => c.cliArg).toList(), + commandLineOnly: true, ), - SSHNPArg( + const SSHNPArg( name: 'add-forwards-to-tunnel', defaultsTo: false, help: 'When true, any local forwarding directives provided in' '--local-ssh-options will be added to the initial tunnel ssh request', format: ArgFormat.flag, + commandLineOnly: true, + ), + const SSHNPArg( + name: 'config-file', + help: 'Read args from a config file\nMandatory args are not required if already supplied in the config file', + commandLineOnly: true, + ), + const SSHNPArg( + name: 'list-devices', + aliases: ['ls'], + negatable: false, + help: 'List available devices', + commandLineOnly: true, ), ]; @@ -195,3 +214,47 @@ class SSHNPArg { return 'SSHNPArg{format: $format, name: $name, abbr: $abbr, help: $help, mandatory: $mandatory, defaultsTo: $defaultsTo, type: $type}'; } } + +ArgParser createArgParser({ + bool isCommandLine = true, + bool withDefaults = true, +}) { + var parser = ArgParser(); + // Basic arguments + for (SSHNPArg arg in SSHNPArg.args) { + if (arg.commandLineOnly && !isCommandLine) { + continue; + } + switch (arg.format) { + case ArgFormat.option: + parser.addOption( + arg.name, + abbr: arg.abbr, + mandatory: arg.mandatory, + defaultsTo: withDefaults ? arg.defaultsTo?.toString() : null, + help: arg.help, + allowed: arg.allowed, + aliases: arg.aliases ?? const [], + ); + break; + case ArgFormat.multiOption: + parser.addMultiOption( + arg.name, + abbr: arg.abbr, + defaultsTo: withDefaults ? arg.defaultsTo as List? : null, + help: arg.help, + ); + break; + case ArgFormat.flag: + parser.addFlag( + arg.name, + abbr: arg.abbr, + defaultsTo: withDefaults ? arg.defaultsTo as bool? : null, + help: arg.help, + negatable: arg.negatable, + ); + break; + } + } + return parser; +} diff --git a/packages/sshnoports/lib/sshnp/sshnp_impl.dart b/packages/sshnoports/lib/sshnp/sshnp_impl.dart index 2c1a76790..37b86dc30 100644 --- a/packages/sshnoports/lib/sshnp/sshnp_impl.dart +++ b/packages/sshnoports/lib/sshnp/sshnp_impl.dart @@ -198,7 +198,16 @@ class SSHNPImpl implements SSHNP { sshHomeDirectory = getDefaultSshDirectory(homeDirectory); if (!Directory(sshHomeDirectory).existsSync()) { - Directory(sshHomeDirectory).createSync(); + try { + Directory(sshHomeDirectory).createSync(); + } catch (e, s) { + throw SSHNPFailed( + 'Unable to create ssh home directory $sshHomeDirectory\n' + 'hint: try manually creating $sshHomeDirectory and re-running sshnp', + e, + s, + ); + } } // previously, the default value for sendSshPublicKey was 'false' instead of '' @@ -210,7 +219,7 @@ class SSHNPImpl implements SSHNP { path.normalize(sendSshPublicKey).contains(r'\')) { publicKeyFileName = path.normalize(path.absolute(sendSshPublicKey)); } else { - publicKeyFileName = path.normalize('$sshHomeDirectory$sendSshPublicKey'); + publicKeyFileName = path.normalize('$sshHomeDirectory/$sendSshPublicKey'); } } @@ -301,11 +310,14 @@ class SSHNPImpl implements SSHNP { } return sshnp; - } catch (e) { + } catch (e, s) { printVersion(); stdout.writeln(SSHNPPartialParams.parser.usage); stderr.writeln('\n$e'); - rethrow; + if (e is SSHNPFailed) { + rethrow; + } + throw SSHNPFailed('Unknown failure:\n$e', e, s); } } @@ -328,9 +340,15 @@ class SSHNPImpl implements SSHNP { // determine the ssh direction direct = useDirectSsh(legacyDaemon, host); - - if (!(await atSignIsActivated(atClient, sshnpdAtSign))) { - throw ('sshnpd atSign $sshnpdAtSign is not activated.'); + try { + if (!(await atSignIsActivated(atClient, sshnpdAtSign))) { + throw ('Device address $sshnpdAtSign is not activated.'); + } + } catch (e, s) { + throw SSHNPFailed( + 'Device address $sshnpdAtSign does not exist or is not activated.', + e, + s); } logger.info('Subscribing to notifications on $sessionId.$namespace@'); @@ -340,22 +358,26 @@ class SSHNPImpl implements SSHNP { .listen(handleSshnpdResponses); if (publicKeyFileName.isNotEmpty && !File(publicKeyFileName).existsSync()) { - throw ('\n Unable to find ssh public key file : $publicKeyFileName'); + throw ('Unable to find ssh public key file : $publicKeyFileName'); } if (publicKeyFileName.isNotEmpty && !File(publicKeyFileName.replaceAll('.pub', '')).existsSync()) { - throw ('\n Unable to find matching ssh private key for public key : $publicKeyFileName'); + throw ('Unable to find matching ssh private key for public key : $publicKeyFileName'); } remoteUsername ?? await fetchRemoteUserName(); // find a spare local port if (localPort == 0) { - ServerSocket serverSocket = - await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); - localPort = serverSocket.port; - await serverSocket.close(); + try { + ServerSocket serverSocket = + await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + localPort = serverSocket.port; + await serverSocket.close(); + } catch (e, s) { + throw SSHNPFailed('Unable to find a spare local port', e, s); + } } await sharePublicKeyWithSshnpdIfRequired(); @@ -369,18 +391,28 @@ class SSHNPImpl implements SSHNP { // 1) generate some ephemeral keys for the daemon to use to ssh back to us // 2) if legacy then we share the private key via its own notification if (!direct) { - var (String ephemeralPublicKey, String ephemeralPrivateKey) = - await generateSshKeys( - rsa: rsa, - sessionId: sessionId, - sshHomeDirectory: sshHomeDirectory); - sshPublicKey = ephemeralPublicKey; - sshPrivateKey = ephemeralPrivateKey; - - await addEphemeralKeyToAuthorizedKeys( - sshPublicKey: sshPublicKey, - localSshdPort: localSshdPort, - sessionId: sessionId); + try { + var (String ephemeralPublicKey, String ephemeralPrivateKey) = + await generateSshKeys( + rsa: rsa, + sessionId: sessionId, + sshHomeDirectory: sshHomeDirectory); + + sshPublicKey = ephemeralPublicKey; + sshPrivateKey = ephemeralPrivateKey; + } catch (e, s) { + throw SSHNPFailed('Failed to generate ephemeral keypair', e, s); + } + + try { + await addEphemeralKeyToAuthorizedKeys( + sshPublicKey: sshPublicKey, + localSshdPort: localSshdPort, + sessionId: sessionId); + } catch (e, s) { + throw SSHNPFailed( + 'Failed to add ephemeral key to authorized_keys', e, s); + } if (legacyDaemon) { await sharePrivateKeyWithSshnpd(); @@ -452,7 +484,8 @@ class SSHNPImpl implements SSHNP { bool acked = await waitForDaemonResponse(); if (!acked) { - return SSHNPFailed('sshnp timed out: waiting for daemon response'); + return SSHNPFailed( + 'sshnp timed out: waiting for daemon response\nhint: make sure the device is online'); } if (sshnpdAckErrors) { @@ -467,14 +500,15 @@ class SSHNPImpl implements SSHNP { try { bool success = false; String? errorMessage; - + Process? process; + SSHClient? client; switch (sshClient) { case SupportedSshClient.hostSsh: - (success, errorMessage) = await directSshViaExec(); + (success, errorMessage, process) = await directSshViaExec(); _doneCompleter.complete(); break; case SupportedSshClient.pureDart: - (success, errorMessage) = await directSshViaSSHClient(); + (success, errorMessage, client) = await directSshViaSSHClient(); break; } @@ -489,40 +523,50 @@ class SSHNPImpl implements SSHNP { remoteUsername: remoteUsername, host: 'localhost', privateKeyFileName: publicKeyFileName.replaceAll('.pub', ''), + localSshOptions: (addForwardsToTunnel) ? null : localSshOptions, + sshProcess: process, + sshClient: client, ); } catch (e, s) { return SSHNPFailed('SSH Client failure : $e', e, s); } } - Future<(bool, String?)> directSshViaSSHClient() async { + Future<(bool, String?, SSHClient?)> directSshViaSSHClient() async { late final SSHSocket socket; try { socket = await SSHSocket.connect(host, _sshrvdPort); } catch (e) { - return (false, 'Failed to open socket to $host:$port : $e'); + return (false, 'Failed to open socket to $host:$port : $e', null); } late final SSHClient client; try { - client = SSHClient(socket, - username: remoteUsername!, - identities: [ - // A single private key file may contain multiple keys. - ...SSHKeyPair.fromPem(ephemeralPrivateKey) - ], - keepAliveInterval: Duration(seconds: 15)); + client = SSHClient( + socket, + username: remoteUsername!, + identities: [ + // A single private key file may contain multiple keys. + ...SSHKeyPair.fromPem(ephemeralPrivateKey) + ], + keepAliveInterval: Duration(seconds: 15), + ); } catch (e) { return ( false, - 'Failed to create SSHClient for $username@$host:$port : $e' + 'Failed to create SSHClient for $username@$host:$port : $e', + null ); } try { await client.authenticated; } catch (e) { - return (false, 'Failed to authenticate as $username@$host:$port : $e'); + return ( + false, + 'Failed to authenticate as $username@$host:$port : $e', + null + ); } int counter = 0; @@ -617,15 +661,16 @@ class SSHNPImpl implements SSHNP { } }); - return (true, null); + return (true, null, client); } - Future<(bool, String?)> directSshViaExec() async { + Future<(bool, String?, Process?)> directSshViaExec() async { // If using exec then we can assume we're on something unix-y // So we can write the ephemeralPrivateKey to a tmp file, // set its permissions appropriately, and remove it after we've // executed the command - var tmpFileName = '/tmp/ephemeral_$sessionId'; + var tmpFileName = + path.normalize('$sshHomeDirectory/tmp/ephemeral_$sessionId'); File tmpFile = File(tmpFileName); await tmpFile.create(recursive: true); await tmpFile.writeAsString(ephemeralPrivateKey, @@ -659,15 +704,14 @@ class SSHNPImpl implements SSHNP { late int sshExitCode; final soutBuf = StringBuffer(); final serrBuf = StringBuffer(); + Process? process; try { - Process process = await Process.start('/usr/bin/ssh', args); - process.stdout.listen((List l) { - var s = utf8.decode(l); + process = await Process.start('/usr/bin/ssh', args); + process.stdout.transform(Utf8Decoder()).listen((String s) { soutBuf.write(s); logger.info('$sessionId | sshStdOut | $s'); }, onError: (e) {}); - process.stderr.listen((List l) { - var s = utf8.decode(l); + process.stderr.transform(Utf8Decoder()).listen((String s) { serrBuf.write(s); logger.info('$sessionId | sshStdErr | $s'); }, onError: (e) {}); @@ -693,7 +737,7 @@ class SSHNPImpl implements SSHNP { } } - return (sshExitCode == 0, errorMessage); + return (sshExitCode == 0, errorMessage, process); } /// Identical to [legacyStartReverseSsh] except for the request notification @@ -702,7 +746,7 @@ class SSHNPImpl implements SSHNP { // sshnp (this program) can then exit without issue. SSHRV sshrv = sshrvGenerator(host, _sshrvdPort, localSshdPort: localSshdPort); - unawaited(sshrv.run()); + Future sshrvResult = sshrv.run(); // send request to the daemon via notification await _notify( @@ -741,6 +785,8 @@ class SSHNPImpl implements SSHNP { remoteUsername: remoteUsername, host: 'localhost', privateKeyFileName: publicKeyFileName.replaceAll('.pub', ''), + localSshOptions: (addForwardsToTunnel) ? localSshOptions : null, + sshrvResult: sshrvResult, ); } @@ -749,7 +795,7 @@ class SSHNPImpl implements SSHNP { // sshnp (this program) can then exit without issue. SSHRV sshrv = sshrvGenerator(host, _sshrvdPort, localSshdPort: localSshdPort); - unawaited(sshrv.run()); + Future sshrvResult = sshrv.run(); // send request to the daemon via notification await _notify( @@ -767,7 +813,8 @@ class SSHNPImpl implements SSHNP { bool acked = await waitForDaemonResponse(); await cleanUpAfterReverseSsh(this); if (!acked) { - return SSHNPFailed('sshnp timed out: waiting for daemon response'); + return SSHNPFailed( + 'sshnp timed out: waiting for daemon response\nhint: make sure the device is online'); } if (sshnpdAckErrors) { @@ -779,6 +826,8 @@ class SSHNPImpl implements SSHNP { remoteUsername: remoteUsername, host: 'localhost', privateKeyFileName: publicKeyFileName.replaceAll('.pub', ''), + localSshOptions: (addForwardsToTunnel) ? null : localSshOptions, + sshrvResult: sshrvResult, ); } @@ -853,10 +902,14 @@ class SSHNPImpl implements SSHNP { AtKey.fromString('$clientAtSign:username.$namespace$sshnpdAtSign'); try { remoteUsername = (await atClient.get(userNameRecordID)).value as String; - } catch (e) { - stderr.writeln("Device \"$device\" unknown, or username not shared "); + } catch (e, s) { + stderr.writeln("Device \"$device\" unknown, or username not shared"); await cleanUpAfterReverseSsh(this); - rethrow; + throw SSHNPFailed( + "Device unknown, or username not shared\n" + "hint: make sure the device shares username or set remote username manually", + e, + s); } } @@ -876,11 +929,14 @@ class SSHNPImpl implements SSHNP { ..ttr = -1 ..ttl = 10000); await _notify(sendOurPublicKeyToSshnpd, toSshPublicKey); - } catch (e) { + } catch (e, s) { stderr.writeln( "Error opening or validating public key file or sending to remote atSign: $e"); await cleanUpAfterReverseSsh(this); - rethrow; + throw SSHNPFailed( + 'Error opening or validating public key file or sending to remote atSign', + e, + s); } } @@ -928,7 +984,7 @@ class SSHNPImpl implements SSHNP { if (counter == 100) { await cleanUpAfterReverseSsh(this); stderr.writeln('sshnp: connection timeout to sshrvd $host service'); - throw ('sshnp: connection timeout to sshrvd $host service'); + throw ('Connection timeout to sshrvd $host service\nhint: make sure host is valid and online'); } } } diff --git a/packages/sshnoports/lib/sshnp/sshnp_params.dart b/packages/sshnoports/lib/sshnp/sshnp_params.dart index 00e1ae51f..447e4713c 100644 --- a/packages/sshnoports/lib/sshnp/sshnp_params.dart +++ b/packages/sshnoports/lib/sshnp/sshnp_params.dart @@ -6,34 +6,35 @@ class SSHNPParams { /// Since there are multiple sources for these values, we cannot validate /// that they will be provided. If any are null, then the caller must /// handle the error. - late final String? clientAtSign; - late final String? sshnpdAtSign; - late final String? host; + final String? clientAtSign; + final String? sshnpdAtSign; + final String? host; /// Optional Arguments - late final String device; - late final int port; - late final int localPort; + final String device; + final int port; + final int localPort; + final String sendSshPublicKey; + final List localSshOptions; + final bool rsa; + final String? remoteUsername; + final bool verbose; + final String rootDomain; + final int localSshdPort; + final bool legacyDaemon; + final int remoteSshdPort; + final int idleTimeout; + final bool addForwardsToTunnel; + + /// Late variables late final String username; late final String homeDirectory; late final String atKeysFilePath; - late final String sendSshPublicKey; - late final List localSshOptions; - late final bool rsa; - late final String? remoteUsername; - late final bool verbose; - late final String rootDomain; - late final int localSshdPort; - late final bool legacyDaemon; - late final int remoteSshdPort; - late final int idleTimeout; late final String sshClient; - late final bool addForwardsToTunnel; /// Special Arguments - late final String? - profileName; // automatically populated with the filename if from a configFile - late final bool listDevices; + final String? profileName; // automatically populated with the filename if from a configFile + final bool listDevices; SSHNPParams({ required this.clientAtSign, @@ -66,12 +67,55 @@ class SSHNPParams { // Use default atKeysFilePath if not provided - this.atKeysFilePath = - atKeysFilePath ?? getDefaultAtKeysFilePath(homeDirectory, clientAtSign); + this.atKeysFilePath = atKeysFilePath ?? getDefaultAtKeysFilePath(homeDirectory, clientAtSign); this.sshClient = sshClient ?? SSHNP.defaultSshClient.cliArg; } + factory SSHNPParams.empty() { + return SSHNPParams( + profileName: '', + clientAtSign: '', + sshnpdAtSign: '', + host: '', + ); + } + + /// Merge an SSHNPPartialParams objects into an SSHNPParams + /// Params in params2 take precedence over params1 + factory SSHNPParams.merge(SSHNPParams params1, [SSHNPPartialParams? params2]) { + params2 ??= SSHNPPartialParams.empty(); + return SSHNPParams( + profileName: params2.profileName ?? params1.profileName, + clientAtSign: params2.clientAtSign ?? params1.clientAtSign, + sshnpdAtSign: params2.sshnpdAtSign ?? params1.sshnpdAtSign, + host: params2.host ?? params1.host, + device: params2.device ?? params1.device, + port: params2.port ?? params1.port, + localPort: params2.localPort ?? params1.localPort, + atKeysFilePath: params2.atKeysFilePath ?? params1.atKeysFilePath, + sendSshPublicKey: params2.sendSshPublicKey ?? params1.sendSshPublicKey, + localSshOptions: params2.localSshOptions ?? params1.localSshOptions, + rsa: params2.rsa ?? params1.rsa, + remoteUsername: params2.remoteUsername ?? params1.remoteUsername, + verbose: params2.verbose ?? params1.verbose, + rootDomain: params2.rootDomain ?? params1.rootDomain, + localSshdPort: params2.localSshdPort ?? params1.localSshdPort, + listDevices: params2.listDevices ?? params1.listDevices, + legacyDaemon: params2.legacyDaemon ?? params1.legacyDaemon, + remoteSshdPort: params2.remoteSshdPort ?? params1.remoteSshdPort, + idleTimeout: params2.idleTimeout ?? params1.idleTimeout, + sshClient: params2.sshClient ?? params1.sshClient, + addForwardsToTunnel: params2.addForwardsToTunnel ?? params1.addForwardsToTunnel, + ); + } + + factory SSHNPParams.fromFile(String fileName) { + return SSHNPParams.fromPartial(SSHNPPartialParams.fromFile(fileName)); + } + + factory SSHNPParams.fromJson(String json) => SSHNPParams.fromPartial(SSHNPPartialParams.fromJson(json)); + factory SSHNPParams.fromPartial(SSHNPPartialParams partial) { AtSignLogger logger = AtSignLogger(' SSHNPParams '); @@ -89,16 +133,15 @@ class SSHNPParams { device: partial.device ?? SSHNP.defaultDevice, port: partial.port ?? SSHNP.defaultPort, localPort: partial.localPort ?? SSHNP.defaultLocalPort, - sendSshPublicKey: - partial.sendSshPublicKey ?? SSHNP.defaultSendSshPublicKey, - localSshOptions: partial.localSshOptions, + sendSshPublicKey: partial.sendSshPublicKey ?? SSHNP.defaultSendSshPublicKey, + localSshOptions: partial.localSshOptions ?? SSHNP.defaultLocalSshOptions, rsa: partial.rsa ?? defaults.defaultRsa, verbose: partial.verbose ?? defaults.defaultVerbose, remoteUsername: partial.remoteUsername, atKeysFilePath: partial.atKeysFilePath, rootDomain: partial.rootDomain ?? defaults.defaultRootDomain, localSshdPort: partial.localSshdPort ?? defaults.defaultLocalSshdPort, - listDevices: partial.listDevices, + listDevices: partial.listDevices ?? SSHNP.defaultListDevices, legacyDaemon: partial.legacyDaemon ?? SSHNP.defaultLegacyDaemon, remoteSshdPort: partial.remoteSshdPort ?? defaults.defaultRemoteSshdPort, idleTimeout: partial.idleTimeout ?? defaults.defaultIdleTimeout, @@ -107,78 +150,8 @@ class SSHNPParams { ); } - factory SSHNPParams.fromConfigFile(String fileName) { - return SSHNPParams.fromPartial(SSHNPPartialParams.fromConfig(fileName)); - } - - static Future> getConfigFilesFromDirectory( - [String? directory]) async { - var params = []; - - var homeDirectory = getHomeDirectory(throwIfNull: true)!; - directory ??= getDefaultSshnpConfigDirectory(homeDirectory); - var files = Directory(directory).list(); - - await files.forEach((file) { - if (file is! File) return; - if (path.extension(file.path) != '.env') return; - try { - var p = SSHNPParams.fromConfigFile(file.path); - - params.add(p); - } catch (e) { - print('Error reading config file: ${file.path}'); - print(e); - } - }); - - return params; - } - - Future toFile({String? directory, bool overwrite = false}) async { - if (profileName == null || profileName!.isEmpty) { - throw Exception('profileName is null or empty'); - } - - var fileName = profileName!.replaceAll(' ', '_'); - - var file = File(path.join( - directory ?? getDefaultSshnpConfigDirectory(homeDirectory), - '$fileName.env', - )); - - var exists = await file.exists(); - - if (exists && !overwrite) { - throw Exception( - 'Failed to write config file: ${file.path} already exists'); - } - - // FileMode.write will create the file if it does not exist - // and overwrite existing files if it does exist - return file.writeAsString(toConfig(), mode: FileMode.write); - } - - Future deleteFile( - {String? directory, bool overwrite = false}) async { - if (profileName == null || profileName!.isEmpty) { - throw Exception('profileName is null or empty'); - } - - var fileName = profileName!.replaceAll(' ', '_'); - - var file = File(path.join( - directory ?? getDefaultSshnpConfigDirectory(homeDirectory), - '$fileName.env', - )); - - var exists = await file.exists(); - - if (!exists) { - throw Exception('Cannot delete ${file.path}, file does not exist'); - } - - return file.delete(); + factory SSHNPParams.fromConfig(String profileName, List lines) { + return SSHNPParams.fromPartial(SSHNPPartialParams.fromConfig(profileName, lines)); } Map toArgs() { @@ -211,6 +184,7 @@ class SSHNPParams { var key = SSHNPArg.fromName(entry.key).bashName; if (key.isEmpty) continue; var value = entry.value; + if (value == null) continue; if (value is List) { value = value.join(','); } @@ -218,40 +192,44 @@ class SSHNPParams { } return lines.join('\n'); } + + String toJson() { + return jsonEncode(toArgs()); + } } /// A class which contains a subset of the SSHNPParams /// This may be used when part of the params come from separate sources /// e.g. default values from a config file and the rest from the command line class SSHNPPartialParams { + // Non param variables + static final ArgParser parser = createArgParser(); + /// Main Params - late final String? profileName; - late final String? clientAtSign; - late final String? sshnpdAtSign; - late final String? host; - late final String? device; - late final int? port; - late final int? localPort; - late final int? localSshdPort; - late final String? atKeysFilePath; - late final String? sendSshPublicKey; - late final List localSshOptions; - late final bool? rsa; - late final String? remoteUsername; - late final bool? verbose; - late final String? rootDomain; - late final bool? legacyDaemon; - late final int? remoteSshdPort; - late final int? idleTimeout; - late final String? sshClient; - late final bool? addForwardsToTunnel; + final String? profileName; + final String? clientAtSign; + final String? sshnpdAtSign; + final String? host; + final String? device; + final int? port; + final int? localPort; + final int? localSshdPort; + final String? atKeysFilePath; + final String? sendSshPublicKey; + final List? localSshOptions; + final bool? rsa; + final String? remoteUsername; + final bool? verbose; + final String? rootDomain; + final bool? legacyDaemon; + final int? remoteSshdPort; + final int? idleTimeout; + final bool? addForwardsToTunnel; + final String? sshClient; /// Special Params // N.B. config file is a meta param and doesn't need to be included - late final bool listDevices; - - // Non param variables - static final ArgParser parser = _createArgParser(); + final bool? listDevices; SSHNPPartialParams({ this.profileName, @@ -263,7 +241,7 @@ class SSHNPPartialParams { this.localPort, this.atKeysFilePath, this.sendSshPublicKey, - this.localSshOptions = SSHNP.defaultLocalSshOptions, + this.localSshOptions, this.rsa, this.remoteUsername, this.verbose, @@ -283,9 +261,7 @@ class SSHNPPartialParams { /// Merge two SSHNPPartialParams objects together /// Params in params2 take precedence over params1 - /// - localSshOptions are concatenated together as (params1 + params2) - factory SSHNPPartialParams.merge(SSHNPPartialParams params1, - [SSHNPPartialParams? params2]) { + factory SSHNPPartialParams.merge(SSHNPPartialParams params1, [SSHNPPartialParams? params2]) { params2 ??= SSHNPPartialParams.empty(); return SSHNPPartialParams( profileName: params2.profileName ?? params1.profileName, @@ -297,23 +273,36 @@ class SSHNPPartialParams { localPort: params2.localPort ?? params1.localPort, atKeysFilePath: params2.atKeysFilePath ?? params1.atKeysFilePath, sendSshPublicKey: params2.sendSshPublicKey ?? params1.sendSshPublicKey, - localSshOptions: params1.localSshOptions + params2.localSshOptions, + localSshOptions: params2.localSshOptions ?? params1.localSshOptions, rsa: params2.rsa ?? params1.rsa, remoteUsername: params2.remoteUsername ?? params1.remoteUsername, verbose: params2.verbose ?? params1.verbose, rootDomain: params2.rootDomain ?? params1.rootDomain, localSshdPort: params2.localSshdPort ?? params1.localSshdPort, - listDevices: params2.listDevices || params1.listDevices, + listDevices: params2.listDevices ?? params1.listDevices, legacyDaemon: params2.legacyDaemon ?? params1.legacyDaemon, remoteSshdPort: params2.remoteSshdPort ?? params1.remoteSshdPort, idleTimeout: params2.idleTimeout ?? params1.idleTimeout, sshClient: params2.sshClient ?? params1.sshClient, - addForwardsToTunnel: - params2.addForwardsToTunnel ?? params1.addForwardsToTunnel, + addForwardsToTunnel: params2.addForwardsToTunnel ?? params1.addForwardsToTunnel, ); } - factory SSHNPPartialParams.fromArgMap(Map args) { + factory SSHNPPartialParams.fromFile(String fileName) { + var args = ConfigFileRepository.parseConfigFile(fileName); + args['profile-name'] = ConfigFileRepository.toProfileName(fileName); + return SSHNPPartialParams.fromMap(args); + } + + factory SSHNPPartialParams.fromConfig(String profileName, List lines) { + var args = ConfigFileRepository.parseConfigFileContents(lines); + args['profile-name'] = profileName; + return SSHNPPartialParams.fromMap(args); + } + + factory SSHNPPartialParams.fromJson(String json) => SSHNPPartialParams.fromMap(jsonDecode(json)); + + factory SSHNPPartialParams.fromMap(Map args) { return SSHNPPartialParams( profileName: args['profile-name'], clientAtSign: args['from'], @@ -324,14 +313,13 @@ class SSHNPPartialParams { localPort: args['local-port'], atKeysFilePath: args['key-file'], sendSshPublicKey: args['ssh-public-key'], - localSshOptions: - args['local-ssh-options'] ?? SSHNP.defaultLocalSshOptions, + localSshOptions: List.from(args['local-ssh-options'] ?? []), rsa: args['rsa'], remoteUsername: args['remote-user-name'], verbose: args['verbose'], rootDomain: args['root-domain'], localSshdPort: args['local-sshd-port'], - listDevices: args['list-devices'] ?? SSHNP.defaultListDevices, + listDevices: args['list-devices'], legacyDaemon: args['legacy-daemon'], remoteSshdPort: args['remote-sshd-port'], idleTimeout: args['idle-timeout'], @@ -340,150 +328,30 @@ class SSHNPPartialParams { ); } - factory SSHNPPartialParams.fromConfig(String fileName) { - var args = _parseConfigFile(fileName); - args['profile-name'] = - path.basenameWithoutExtension(fileName).replaceAll('_', ' '); - return SSHNPPartialParams.fromArgMap(args); - } - /// Parses args from command line /// first merges from a config file if provided via --config-file factory SSHNPPartialParams.fromArgs(List args) { var params = SSHNPPartialParams.empty(); - var parsedArgs = _createArgParser(withDefaults: false).parse(args); + var parsedArgs = createArgParser(withDefaults: false).parse(args); if (parsedArgs.wasParsed('config-file')) { var configFileName = parsedArgs['config-file'] as String; params = SSHNPPartialParams.merge( params, - SSHNPPartialParams.fromConfig(configFileName), + SSHNPPartialParams.fromFile(configFileName), ); } // THIS IS A WORKAROUND IN ORDER TO BE TYPE SAFE IN SSHNPPartialParams.fromArgMap Map parsedArgsMap = { for (var e in parsedArgs.options) - e: SSHNPArg.fromName(e).type == ArgType.integer - ? int.tryParse(parsedArgs[e]) - : parsedArgs[e] + e: SSHNPArg.fromName(e).type == ArgType.integer ? int.tryParse(parsedArgs[e]) : parsedArgs[e] }; return SSHNPPartialParams.merge( params, - SSHNPPartialParams.fromArgMap(parsedArgsMap), + SSHNPPartialParams.fromMap(parsedArgsMap), ); } - - static ArgParser _createArgParser({ - bool withConfig = true, - bool withDefaults = true, - bool withListDevices = true, - }) { - var parser = ArgParser(); - // Basic arguments - for (SSHNPArg arg in SSHNPArg.args) { - switch (arg.format) { - case ArgFormat.option: - parser.addOption( - arg.name, - abbr: arg.abbr, - mandatory: arg.mandatory, - defaultsTo: withDefaults ? arg.defaultsTo?.toString() : null, - allowed: arg.allowed, - help: arg.help, - ); - break; - case ArgFormat.multiOption: - parser.addMultiOption( - arg.name, - abbr: arg.abbr, - defaultsTo: withDefaults ? arg.defaultsTo as List? : null, - allowed: arg.allowed, - help: arg.help, - ); - break; - case ArgFormat.flag: - parser.addFlag( - arg.name, - abbr: arg.abbr, - defaultsTo: withDefaults ? arg.defaultsTo as bool? : null, - help: arg.help, - ); - break; - } - } - if (withConfig) { - parser.addOption( - 'config-file', - help: - 'Read args from a config file\nMandatory args are not required if already supplied in the config file', - ); - } - if (withListDevices) { - parser.addFlag( - 'list-devices', - aliases: ['ls'], - negatable: false, - help: 'List available devices', - ); - } - return parser; - } - - static Map _parseConfigFile(String fileName) { - Map args = {}; - - File file = File(fileName); - - if (!file.existsSync()) { - throw Exception('Config file does not exist: $fileName'); - } - try { - List lines = file.readAsLinesSync(); - - for (String line in lines) { - if (line.startsWith('#')) continue; - - var parts = line.split('='); - if (parts.length != 2) continue; - - var key = parts[0].trim(); - var value = parts[1].trim(); - - SSHNPArg arg = SSHNPArg.fromBashName(key); - if (arg.name.isEmpty) continue; - - switch (arg.format) { - case ArgFormat.flag: - if (value.toLowerCase() == 'true') { - args[arg.name] = true; - } - continue; - case ArgFormat.multiOption: - var values = value.split(','); - args.putIfAbsent(arg.name, () => []); - for (String val in values) { - if (val.isEmpty) continue; - args[arg.name].add(val); - } - continue; - case ArgFormat.option: - if (value.isEmpty) continue; - if (arg.type == ArgType.integer) { - args[arg.name] = int.tryParse(value); - } else { - args[arg.name] = value; - } - continue; - } - } - return args; - } on FileSystemException { - throw Exception('Error reading config file: $fileName'); - } catch (e) { - throw Exception('Error parsing config file: $fileName'); - } - } } diff --git a/packages/sshnoports/lib/sshnp/sshnp_result.dart b/packages/sshnoports/lib/sshnp/sshnp_result.dart index 6338b6e0a..51f48d97d 100644 --- a/packages/sshnoports/lib/sshnp/sshnp_result.dart +++ b/packages/sshnoports/lib/sshnp/sshnp_result.dart @@ -2,12 +2,14 @@ part of 'sshnp.dart'; abstract class SSHNPResult {} -const _optionsWithPrivateKey = [ - '-o StrictHostKeyChecking=accept-new', - '-o IdentitiesOnly=yes' -]; +abstract class SSHNPCommandResult implements SSHNPResult { + String get command; + List get args; +} + +const _optionsWithPrivateKey = ['-o StrictHostKeyChecking=accept-new', '-o IdentitiesOnly=yes']; -class SSHNPFailed extends SSHNPResult { +class SSHNPFailed implements SSHNPResult { final String message; final Object? exception; final StackTrace? stackTrace; @@ -20,8 +22,9 @@ class SSHNPFailed extends SSHNPResult { } } -class SSHCommand extends SSHNPResult { - static const String command = 'ssh'; +class SSHCommand implements SSHNPCommandResult { + @override + final String command = 'ssh'; final int localPort; final String? remoteUsername; @@ -30,35 +33,42 @@ class SSHCommand extends SSHNPResult { final List sshOptions; + Future? sshrvResult; + Process? sshProcess; + SSHClient? sshClient; + SSHCommand.base({ required this.localPort, required this.remoteUsername, required this.host, + List? localSshOptions, this.privateKeyFileName, - }) : sshOptions = (shouldIncludePrivateKey(privateKeyFileName) - ? _optionsWithPrivateKey - : []); + this.sshrvResult, + this.sshProcess, + this.sshClient, + }) : sshOptions = [ + if (shouldIncludePrivateKey(privateKeyFileName)) ..._optionsWithPrivateKey, + ...(localSshOptions ?? []) + ]; static bool shouldIncludePrivateKey(String? privateKeyFileName) => privateKeyFileName != null && privateKeyFileName.isNotEmpty; + @override + List get args => [ + '-p $localPort', + ...sshOptions, + if (remoteUsername != null) '$remoteUsername@$host', + if (remoteUsername == null) host, + if (shouldIncludePrivateKey(privateKeyFileName)) ...['-i', '$privateKeyFileName'], + ]; + @override String toString() { final sb = StringBuffer(); sb.write(command); sb.write(' '); - sb.write('-p $localPort'); - sb.write(' '); - sb.write(sshOptions.join(' ')); - sb.write(' '); - if (remoteUsername != null) { - sb.write('$remoteUsername@'); - } - sb.write(host); - if (shouldIncludePrivateKey(privateKeyFileName)) { - sb.write(' '); - sb.write('-i $privateKeyFileName'); - } + sb.write(args.join(' ')); return sb.toString(); } } diff --git a/packages/sshnoports/lib/sshnp/utils.dart b/packages/sshnoports/lib/sshnp/utils.dart index 22aab9586..b7d7894d1 100644 --- a/packages/sshnoports/lib/sshnp/utils.dart +++ b/packages/sshnoports/lib/sshnp/utils.dart @@ -17,17 +17,12 @@ Future cleanUpAfterReverseSsh(SSHNP sshnp) async { if (homeDirectory == null) { return; } - var sshHomeDirectory = "$homeDirectory/.ssh/"; - if (Platform.isWindows) { - sshHomeDirectory = r'$homeDirectory\.ssh\'; - } + var sshHomeDirectory = getDefaultSshDirectory(homeDirectory); sshnp.logger.info('Tidying up files'); // Delete the generated RSA keys and remove the entry from ~/.ssh/authorized_keys - await deleteFile('$sshHomeDirectory${sshnp.sessionId}_sshnp', sshnp.logger); - await deleteFile( - '$sshHomeDirectory${sshnp.sessionId}_sshnp.pub', sshnp.logger); - await removeEphemeralKeyFromAuthorizedKeys(sshnp.sessionId, sshnp.logger, - sshHomeDirectory: sshHomeDirectory); + await deleteFile('$sshHomeDirectory/${sshnp.sessionId}_sshnp', sshnp.logger); + await deleteFile('$sshHomeDirectory/${sshnp.sessionId}_sshnp.pub', sshnp.logger); + await removeEphemeralKeyFromAuthorizedKeys(sshnp.sessionId, sshnp.logger, sshHomeDirectory: sshHomeDirectory); } Future deleteFile(String fileName, AtSignLogger logger) async { diff --git a/packages/sshnoports/lib/sshnpd/sshnpd_impl.dart b/packages/sshnoports/lib/sshnpd/sshnpd_impl.dart index 5e2365a56..698f5b4ca 100644 --- a/packages/sshnoports/lib/sshnpd/sshnpd_impl.dart +++ b/packages/sshnoports/lib/sshnpd/sshnpd_impl.dart @@ -240,7 +240,7 @@ class SSHNPDImpl implements SSHNPD { case 'sshd': logger.info( - '<3.4.0 request for (reverse) ssh received from ${notification.from}' + '<4.0.0 request for (reverse) ssh received from ${notification.from}' ' ( notification id : ${notification.id} )'); _handleLegacySshRequestNotification(notification); break; @@ -250,7 +250,7 @@ class SSHNPDImpl implements SSHNPD { break; case 'ssh_request': - logger.info('>=3.5.0 request for ssh received from ${notification.from}' + logger.info('>=4.0.0 request for ssh received from ${notification.from}' ' ( $notification )'); _handleSshRequestNotification(notification); break; diff --git a/packages/sshnoports/lib/sshrv/sshrv.dart b/packages/sshnoports/lib/sshrv/sshrv.dart index a9da4644d..7453c504c 100644 --- a/packages/sshnoports/lib/sshrv/sshrv.dart +++ b/packages/sshnoports/lib/sshrv/sshrv.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:at_utils/at_utils.dart'; import 'package:meta/meta.dart'; import 'package:socket_connector/socket_connector.dart'; diff --git a/packages/sshnoports/lib/sshrv/sshrv_impl.dart b/packages/sshnoports/lib/sshrv/sshrv_impl.dart index e6abc82d0..86a979358 100644 --- a/packages/sshnoports/lib/sshrv/sshrv_impl.dart +++ b/packages/sshnoports/lib/sshrv/sshrv_impl.dart @@ -65,7 +65,7 @@ class SSHRVImplPureDart implements SSHRV { verbose: true, ); } catch (e) { - print('sshrv error: ${e.toString()}'); + AtSignLogger('sshrv').severe(e.toString()); rethrow; } } diff --git a/packages/sshnoports/lib/version.dart b/packages/sshnoports/lib/version.dart index 39039f0fd..49030075a 100644 --- a/packages/sshnoports/lib/version.dart +++ b/packages/sshnoports/lib/version.dart @@ -1,7 +1,7 @@ import 'dart:io'; // Note: if you update this version also update pubspec.yaml -const String version = "4.0.0-rc.3"; +const String version = "4.0.0-rc.5"; /// Print version number void printVersion() { diff --git a/packages/sshnoports/pubspec.yaml b/packages/sshnoports/pubspec.yaml index 6550b9c22..132c95603 100644 --- a/packages/sshnoports/pubspec.yaml +++ b/packages/sshnoports/pubspec.yaml @@ -3,7 +3,7 @@ description: Encrypted/Secure control plane for ssh/d or other commands in the f # NOTE: If you update the version number here, you # must also update it in version.dart -version: 4.0.0-rc.3 +version: 4.0.0-rc.5 homepage: https://docs.atsign.com/ diff --git a/packages/sshnp_gui/lib/l10n/app_en.arb b/packages/sshnp_gui/lib/l10n/app_en.arb index 3831a80a9..85999dda4 100644 --- a/packages/sshnp_gui/lib/l10n/app_en.arb +++ b/packages/sshnp_gui/lib/l10n/app_en.arb @@ -1,69 +1,80 @@ { - "currentConnections" : "Current Connections", - "addNewConnection" : "Add New Connection", + "actions" : "Actions", "add" : "Add", - "hostSelection" : "Host Selection", - "host" : "Host", - "sourcePort" : "Source Port", - "status" : "Status", - "destination" : "Destination", - "dest" : "Dest.", - "options" : "Options", - "connect" : "Connect", + "addNewConnection" : "Add New Connection", + "atKeysFilePath" : "atKeys File", + "availableConnections" : "Available Connections", + "backupYourKeys" : "Backup Your Keys", "cancel" : "Cancel", - "newText" : "New", - "profileName" : "Profile Name", + "cancelButton":"Cancel", "clientAtsign" : "Client atsign", - "sshnpdAtSign" : "SSHNP atsign", - "sshnpdAtSignHint" : "The atSign of the sshnpd we wish to communicate with", - "device" : "Device", + "closeButton" : "Close", + "connect" : "Connect", + "contactUs" : "Contact Us", + "copiedToClipboard": "Copied to Clipboard", + "corruptedProfile": "Status: profile is corrupted", + "currentConnections" : "Current Connections", + "deleteButton" : "Delete", + "delete" : "Delete", + "dest" : "Dest.", + "destination" : "Destination", + "device" : "Device Name", "deviceHint" : "The device name of the sshnpd we wish to communicate with", - "username" : "Username", - "usernameHint": "The user name on this host", + "edit" : "Edit", + "export" : "Export", + "error" : "Error", + "failed": "Failed", + "faq" : "FAQ", + "from" : "From", "homeDirectory" : "Home Directory", "homeDirectoryHint" : "The home directory on this host", - "sessionId" : "Session ID", - "sendSshPublicKey" : "Send SSH Public Key", - "rsa" : "RSA", - "keyFile" : "Key File", - "from" : "From", - "to" : "To", "host" : "Host", - "port" : "Port", + "host" : "Host", + "hostSelection" : "Host Selection", + "import" : "Import", + "importProfile" : "Import Profile", + "keyFile" : "Key File", + "listDevices" : "List Devices", "localPort": "Local Port", - "sshPublicKey" : "SSH Public Key", + "localSshdPort": "Local SSHD Port", "localSshOptions" : "Local SSH Options", - "verbose" : "verbose", - "remoteUserName" : "Remote User Name", - "atKeysFilePath" : "atKeys File Path", - "rootDomain" : "Root Domain", - "listDevices" : "List Devices", - "availableConnections" : "Available Connections", - "actions" : "Actions", - "warningMessage" : " Are you sure you want to delete this configuration", - "warning": "Warning", + "localSshOptionsHint" : "Use \",\" to separate options", + "newText" : "New", + "noAtsignToReset": "No atSigns are paired to reset.", "note" : "Note", "noteMessage" : ": You cannot undo this action.", - "cancelButton":"Cancel", - "deleteButton" : "Delete", + "noTerminalSessions" : "No active terminal sessions", + "noTerminalSessionsHelp" : "Create a new session from the home screen", "okButton" : "Ok", - "sshButton" : "ssh", - "closeButton" : "Close", - "success": "Success", - "failed": "Failed", - "result" : "Result", - "copiedToClipboard": "Copied to Clipboard", - "settings" : "Settings", - "backupYourKeys" : "BackupYourKeys", - "switchAtsign" : "Switch atSign", - "faq" : "FAQ", - "contactUs" : "Contact Us", + "options" : "Options", + "port" : "Remote Port", "privacyPolicy" : "Privacy Policy", + "profileName" : "Profile Name", + "remoteUserName" : "Remote Username", + "removeButton":"Remove", "reset" : "Reset", "resetDescription":"This will remove the selected atSign and its details from this app only.", - "noAtsignToReset": "No atSigns are paired to reset.", "resetErrorText":"Please select atleast one atSign to reset", "resetWarningText":"Warning: This action cannot be undone", - "removeButton":"Remove", - "error" : "Error" + "result" : "Result", + "rootDomain" : "Root Domain", + "rsa" : "Legacy RSA Key", + "sendSshPublicKey" : "SSH Public Key", + "sessionId" : "Session ID", + "settings" : "Settings", + "sourcePort" : "Source Port", + "sshButton" : "ssh", + "sshnpdAtSign" : "Device Address", + "sshnpdAtSignHint" : "The atSign of the sshnpd we wish to communicate with", + "sshPublicKey" : "SSH Public Key", + "status" : "Status", + "submit" : "Submit", + "success": "Success", + "switchAtsign" : "Switch atSign", + "to" : "To", + "username" : "Username", + "usernameHint": "The user name on this host", + "verbose" : "Verbose Logging", + "warning": "Warning", + "warningMessage" : " Are you sure you want to delete this configuration" } \ No newline at end of file diff --git a/packages/sshnp_gui/lib/main.dart b/packages/sshnp_gui/lib/main.dart index 1741e862b..1a69f505c 100644 --- a/packages/sshnp_gui/lib/main.dart +++ b/packages/sshnp_gui/lib/main.dart @@ -3,13 +3,8 @@ import 'dart:async'; import 'package:at_app_flutter/at_app_flutter.dart' show AtEnv; import 'package:at_utils/at_logger.dart' show AtSignLogger; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:macos_ui/macos_ui.dart'; - -import 'src/utils/app_router.dart'; -import 'src/utils/theme.dart'; -import 'src/utils/util.dart'; +import 'package:sshnp_gui/src/utility/platform_utility/platform_utililty.dart'; final AtSignLogger _logger = AtSignLogger(AtEnv.appNamespace); @@ -22,51 +17,7 @@ Future main() async { _logger.finer('Environment failed to load from .env: ', e); } - /// This method initializes macos_window_utils and styles the window. - Future _configureMacosWindowUtils() async { - const config = MacosWindowUtilsConfig(toolbarStyle: NSWindowToolbarStyle.unified); - await config.apply(); - } - - if (Util.isMacos()) { - // await _configureMacosWindowUtils(); - - runApp(const ProviderScope(child: MyApp())); - } else { - runApp(const ProviderScope(child: MyApp())); - } -} - -class MyApp extends ConsumerWidget { - const MyApp({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return MaterialApp.router( - title: 'SSHNP', - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - routerConfig: ref.watch(goRouterProvider), - theme: AppTheme.dark(), - // * The onboarding screen (first screen)p[] - ); - } -} - -class MyMacApp extends ConsumerWidget { - const MyMacApp({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return MacosApp.router( - title: 'SSHNP', - localizationsDelegates: AppLocalizations.localizationsDelegates, - supportedLocales: AppLocalizations.supportedLocales, - routerConfig: ref.watch(goRouterProvider), - theme: AppTheme.macosDark(), - darkTheme: AppTheme.macosDark(), - themeMode: ThemeMode.dark, - // * The onboarding screen (first screen)p[] - ); - } + PlatformUtility platformUtility = PlatformUtility.current(); + await platformUtility.configurePlatform(); + runApp(ProviderScope(child: platformUtility.app)); } diff --git a/packages/sshnp_gui/lib/src/controllers/authentication_controller.dart b/packages/sshnp_gui/lib/src/controllers/authentication_controller.dart index ad9db6d7d..684ae3ee1 100644 --- a/packages/sshnp_gui/lib/src/controllers/authentication_controller.dart +++ b/packages/sshnp_gui/lib/src/controllers/authentication_controller.dart @@ -1,7 +1,10 @@ import 'package:at_contact/at_contact.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sshnp_gui/src/repository/authentication_repository.dart'; -import '../repository/authentication_repository.dart'; +/// A provider that exposes the [AuthenticationController] to the app. +final authenticationController = StateNotifierProvider?>>( + (ref) => AuthenticationController(ref: ref)); /// A controller class that controls the UI update when the [AuthenticationRepository] methods are called. class AuthenticationController extends StateNotifier?>> { @@ -30,7 +33,3 @@ class AuthenticationController extends StateNotifier?>> return await ref.watch(authenticationRepositoryProvider).getCurrentAtContact(); } } - -/// A provider that exposes the [AuthenticationController] to the app. -final authenticationController = StateNotifierProvider?>>( - (ref) => AuthenticationController(ref: ref)); diff --git a/packages/sshnp_gui/lib/src/controllers/background_session_controller.dart b/packages/sshnp_gui/lib/src/controllers/background_session_controller.dart new file mode 100644 index 000000000..d2a848f6d --- /dev/null +++ b/packages/sshnp_gui/lib/src/controllers/background_session_controller.dart @@ -0,0 +1,23 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum BackgroundSessionStatus { stopped, loading, running } + +final backgroundSessionFamilyController = + NotifierProviderFamily( + BackgroundSessionFamilyController.new, +); + +class BackgroundSessionFamilyController extends FamilyNotifier { + @override + BackgroundSessionStatus build(String arg) { + return BackgroundSessionStatus.stopped; + } + + void setStatus(BackgroundSessionStatus status) { + state = status; + } + + void start() => setStatus(BackgroundSessionStatus.loading); + void endStartUp() => setStatus(BackgroundSessionStatus.running); + void stop() => setStatus(BackgroundSessionStatus.stopped); +} diff --git a/packages/sshnp_gui/lib/src/controllers/config_controller.dart b/packages/sshnp_gui/lib/src/controllers/config_controller.dart new file mode 100644 index 000000000..403bbd3f3 --- /dev/null +++ b/packages/sshnp_gui/lib/src/controllers/config_controller.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:at_client_mobile/at_client_mobile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sshnoports/sshnp/sshnp.dart'; +import 'package:sshnoports/sshnp/config_repository/config_key_repository.dart'; +import 'package:sshnp_gui/src/presentation/widgets/utility/custom_snack_bar.dart'; + +enum ConfigFileWriteState { create, update } + +/// A provider that exposes the [CurrentConfigController] to the app. +final currentConfigController = AutoDisposeNotifierProvider( + CurrentConfigController.new, +); + +/// A provider that exposes the [ConfigListController] to the app. +final configListController = AutoDisposeAsyncNotifierProvider>( + ConfigListController.new, +); + +/// A provider that exposes the [ConfigFamilyController] to the app. +final configFamilyController = AutoDisposeAsyncNotifierProviderFamily( + ConfigFamilyController.new, +); + +/// Holder model for the current [SSHNPParams] being edited +class CurrentConfigState { + final String profileName; + final ConfigFileWriteState configFileWriteState; + + CurrentConfigState({required this.profileName, required this.configFileWriteState}); +} + +/// Controller for the current [SSHNPParams] being edited +class CurrentConfigController extends AutoDisposeNotifier { + @override + CurrentConfigState build() { + return CurrentConfigState( + profileName: '', + configFileWriteState: ConfigFileWriteState.create, + ); + } + + void setState(CurrentConfigState model) { + state = model; + } +} + +/// Controller for the list of all profileNames for each config file +class ConfigListController extends AutoDisposeAsyncNotifier> { + @override + Future> build() async { + AtClient atClient = AtClientManager.getInstance().atClient; + return Set.from(await ConfigKeyRepository.listProfiles(atClient)); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(() => build()); + } + + void add(String profileName) { + state = AsyncValue.data({...state.value ?? [], profileName}); + } + + void remove(String profileName) { + state = AsyncData(state.value?.where((e) => e != profileName) ?? []); + } +} + +/// Controller for the family of [SSHNPParams] controllers +class ConfigFamilyController extends AutoDisposeFamilyAsyncNotifier { + @override + Future build(String arg) async { + AtClient atClient = AtClientManager.getInstance().atClient; + if (arg.isEmpty) { + return SSHNPParams.merge( + SSHNPParams.empty(), + SSHNPPartialParams(clientAtSign: atClient.getCurrentAtSign()!), + ); + } + return ConfigKeyRepository.getParams(arg, atClient: atClient); + } + + Future putConfig(SSHNPParams params, {String? oldProfileName, BuildContext? context}) async { + AtClient atClient = AtClientManager.getInstance().atClient; + SSHNPParams oldParams = state.value ?? SSHNPParams.empty(); + if (oldProfileName != null) { + ref.read(configFamilyController(oldProfileName).notifier).deleteConfig(context: context); + } + if (params.clientAtSign != atClient.getCurrentAtSign()) { + params = SSHNPParams.merge( + params, + SSHNPPartialParams( + clientAtSign: atClient.getCurrentAtSign(), + ), + ); + } + state = AsyncValue.data(params); + try { + await ConfigKeyRepository.putParams(params, atClient: atClient); + } catch (e) { + if (context?.mounted ?? false) { + CustomSnackBar.error(content: 'Failed to update profile: $arg'); + } + state = AsyncValue.data(oldParams); + } + ref.read(configListController.notifier).add(params.profileName!); + } + + Future deleteConfig({BuildContext? context}) async { + try { + await ConfigKeyRepository.deleteParams(arg, atClient: AtClientManager.getInstance().atClient); + ref.read(configListController.notifier).remove(arg); + state = AsyncValue.error('SSHNPParams has been disposed', StackTrace.current); + } catch (e) { + if (context?.mounted ?? false) { + CustomSnackBar.error(content: 'Failed to delete profile: $arg'); + } + } + } +} diff --git a/packages/sshnp_gui/lib/src/controllers/home_screen_controller.dart b/packages/sshnp_gui/lib/src/controllers/home_screen_controller.dart deleted file mode 100644 index 4917fd0f5..000000000 --- a/packages/sshnp_gui/lib/src/controllers/home_screen_controller.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'dart:developer'; -import 'dart:io'; - -import 'package:at_client_mobile/at_client_mobile.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path/path.dart' as path; -import 'package:sshnoports/common/utils.dart'; -import 'package:sshnoports/sshnp/sshnp.dart'; -import 'package:sshnp_gui/src/controllers/minor_providers.dart'; - -/// A Controller class that controls the UI update when the [AtDataRepository] methods are called. -class HomeScreenController extends StateNotifier>> { - final Ref ref; - - HomeScreenController({required this.ref}) : super(const AsyncValue.loading()); - - /// Get list of config files associated with the current astign. - Future getConfigFiles() async { - state = const AsyncValue.loading(); - state = await AsyncValue.guard(() async { - try { - var sshnpParams = await SSHNPParams.getConfigFilesFromDirectory(); - for (var element in sshnpParams.toList()) { - log(element.sshnpdAtSign.toString()); - } - return sshnpParams.toList(); - } on PathNotFoundException { - log('Path Not Found'); - return []; - } - }); - } - - Future getPublicKeyFromDirectory() async { - var homeDirectory = getHomeDirectory(throwIfNull: true)!; - - var files = Directory('$homeDirectory/.ssh').list(); - final publickey = await files.firstWhere((element) => element.path.contains('sshnp.pub')); - - return publickey.path.split('.ssh/').last; - } - - /// Deletes the [AtKey] associated with the [AtData]. - Future delete(int index) async { - state = const AsyncValue.loading(); - final directory = getDefaultSshnpConfigDirectory(getHomeDirectory()!); - var configDir = await Directory(directory).list().toList(); - // remove non env file so the index of the config file in the UI matches the index of the configDir env files. - //TODO @CurtlyCritchlow this is no longer needed, you can now use [SSHNPParams.deleteFile()] - configDir.removeWhere((element) => path.extension(element.path) != '.env'); - configDir[index].delete(); - await getConfigFiles(); - } - - // /// Deletes all [AtData] associated with the current atsign. - // Future deleteAllData() async { - // state = const AsyncValue.loading(); - // final directory = getDefaultSshnpConfigDirectory(getHomeDirectory()!); - // var configDir = await Directory(directory).list().toList(); - // configDir.map((e) => e.delete()); - // await getConfigFiles(); - // } - - /// create or update config files. - Future createConfigFile( - SSHNPParams sshnpParams, - ) async { - state = const AsyncValue.loading(); - final homeDir = getHomeDirectory()!; - - log(homeDir); - final configDir = getDefaultSshnpConfigDirectory(homeDir); - log(configDir); - await Directory(configDir).create(recursive: true); - //.env - sshnpParams.toFile(overwrite: false); - await getConfigFiles(); - } - - /// create or update config files. - Future updateConfigFile({required SSHNPParams sshnpParams}) async { - state = const AsyncValue.loading(); - - final directory = getDefaultSshnpConfigDirectory(getHomeDirectory()!); - var configDir = await Directory(directory).list().toList(); - configDir.removeWhere((element) => path.extension(element.path) != '.env'); - final index = ref.read(sshnpParamsUpdateIndexProvider); - log('path is:${configDir[index].path}'); - // await Directory(configDir).create(recursive: true); - //.env - sshnpParams.toFile(overwrite: true); - await getConfigFiles(); - } -} - -/// A provider that exposes the [HomeScreenController] to the app. -final homeScreenControllerProvider = - StateNotifierProvider>>((ref) => HomeScreenController(ref: ref)); diff --git a/packages/sshnp_gui/lib/src/controllers/minor_providers.dart b/packages/sshnp_gui/lib/src/controllers/minor_providers.dart deleted file mode 100644 index b89e649dd..000000000 --- a/packages/sshnp_gui/lib/src/controllers/minor_providers.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:sshnoports/sshnp/sshnp.dart'; -import 'package:sshnp_gui/src/utils/enum.dart'; - -final currentNavIndexProvider = StateProvider((ref) => 0); - -final sshnpParamsProvider = StateProvider( - (ref) => SSHNPParams(clientAtSign: '', sshnpdAtSign: '', host: '', legacyDaemon: true), -); - -/// index for the config file that is being updated -final sshnpParamsUpdateIndexProvider = StateProvider( - (ref) => 0, -); - -final configFileWriteStateProvider = StateProvider( - (ref) => ConfigFileWriteState.create, -); -final terminalSSHCommandProvider = StateProvider( - (ref) => '', -); diff --git a/packages/sshnp_gui/lib/src/controllers/navigation_controller.dart b/packages/sshnp_gui/lib/src/controllers/navigation_controller.dart new file mode 100644 index 000000000..a2117546c --- /dev/null +++ b/packages/sshnp_gui/lib/src/controllers/navigation_controller.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sshnp_gui/src/presentation/screens/home_screen.dart'; +import 'package:sshnp_gui/src/presentation/screens/onboarding_screen.dart'; +import 'package:sshnp_gui/src/presentation/screens/profile_editor_screen.dart'; +import 'package:sshnp_gui/src/presentation/screens/settings_screen.dart'; +import 'package:sshnp_gui/src/presentation/screens/terminal_screen.dart'; +import 'package:sshnp_gui/src/repository/navigation_repository.dart'; + +enum AppRoute { + onboarding, + home, + profileForm, + terminal, + settings, +} + +final navigationController = Provider( + (ref) => GoRouter( + navigatorKey: NavigationRepository.navKey, + initialLocation: '/', + debugLogDiagnostics: false, + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const OnboardingScreen(), + name: AppRoute.onboarding.name, + routes: [ + GoRoute( + path: 'home', + name: AppRoute.home.name, + pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, + child: const HomeScreen(), + transitionsBuilder: ((context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child)), + ), + ), + GoRoute( + path: 'new', + name: AppRoute.profileForm.name, + pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, + child: const ProfileEditorScreen(), + transitionsBuilder: ((context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child)), + ), + ), + GoRoute( + path: 'terminal', + name: AppRoute.terminal.name, + pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, + child: const TerminalScreen(), + transitionsBuilder: ((context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child)), + ), + ), + GoRoute( + path: 'settings', + name: AppRoute.settings.name, + pageBuilder: (context, state) => CustomTransitionPage( + key: state.pageKey, + child: const SettingsScreen(), + transitionsBuilder: ((context, animation, secondaryAnimation, child) => + FadeTransition(opacity: animation, child: child)), + ), + ), + ], + ), + ], + ), +); diff --git a/packages/sshnp_gui/lib/src/controllers/navigation_rail_controller.dart b/packages/sshnp_gui/lib/src/controllers/navigation_rail_controller.dart new file mode 100644 index 000000000..6ec233f6e --- /dev/null +++ b/packages/sshnp_gui/lib/src/controllers/navigation_rail_controller.dart @@ -0,0 +1,48 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; + +final navigationRailController = + AutoDisposeNotifierProvider(NavigationRailController.new); + +class NavigationRailController extends AutoDisposeNotifier { + @override + AppRoute build() => AppRoute.home; + + final routes = [ + AppRoute.home, + AppRoute.terminal, + AppRoute.settings, + ]; + + AppRoute getRoute(int index) { + return routes[index]; + } + + int indexOf(AppRoute route) { + return routes.indexOf(route); + } + + bool isCurrentIndex(AppRoute route) { + return state == route; + } + + int getCurrentIndex() { + return indexOf(state); + } + + AppRoute getCurrentRoute() { + return getRoute(getCurrentIndex()); + } + + bool setIndex(int index) { + if (index < 0 || index >= routes.length) return false; + state = routes[index]; + return true; + } + + bool setRoute(AppRoute route) { + if (!routes.contains(route)) return false; + state = route; + return true; + } +} diff --git a/packages/sshnp_gui/lib/src/controllers/terminal_session_controller.dart b/packages/sshnp_gui/lib/src/controllers/terminal_session_controller.dart new file mode 100644 index 000000000..47e2c51dc --- /dev/null +++ b/packages/sshnp_gui/lib/src/controllers/terminal_session_controller.dart @@ -0,0 +1,219 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_pty/flutter_pty.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:uuid/uuid.dart'; +import 'package:xterm/xterm.dart'; + +/// A provider that exposes the [TerminalSessionController] to the app. +final terminalSessionController = NotifierProvider( + TerminalSessionController.new, +); + +/// A provider that exposes the [TerminalSessionListController] to the app. +final terminalSessionListController = NotifierProvider>( + TerminalSessionListController.new, +); + +/// A provider that exposes the [TerminalSessionFamilyController] to the app. +final terminalSessionFamilyController = + NotifierProviderFamily( + TerminalSessionFamilyController.new, +); + +final terminalSessionProfileNameFamilyCounter = + NotifierProviderFamily( + TerminalSessionProfileNameFamilyCounter.new, +); + +/// Controller for the id of the currently active terminal session +class TerminalSessionController extends Notifier { + @override + String build() => ''; + + String createSession() { + state = const Uuid().v4(); + ref.read(terminalSessionListController.notifier)._add(state); + return state; + } + + void setSession(String sessionId) { + state = sessionId; + } +} + +/// Controller for the list of all terminal session ids +class TerminalSessionListController extends Notifier> { + @override + List build() => []; + + void _add(String sessionId) { + state = state + [sessionId]; + } + + void _remove(String sessionId) { + state.remove(sessionId); + } +} + +class TerminalSession { + final String sessionId; + final Terminal terminal; + + String? _profileName; + String displayName; + + late Pty pty; + bool isRunning = false; + bool isDisposed = true; + String? command; + List args = const []; + + TerminalSession(this.sessionId) + : terminal = Terminal(maxLines: 10000), + displayName = sessionId; +} + +/// Controller for the family of terminal session [TerminalController]s +class TerminalSessionFamilyController extends FamilyNotifier { + @override + TerminalSession build(String arg) { + return TerminalSession(arg); + } + + String get displayName => state.displayName; + + void issueDisplayName(String profileName) { + state._profileName = profileName; + state.displayName = + ref.read(terminalSessionProfileNameFamilyCounter(profileName).notifier)._addSession(state.sessionId); + } + + void setProcess({String? command, List args = const []}) { + state.command = command; + state.args = args; + } + + void startProcess() { + if (state.isRunning) return; + state.isRunning = true; + state.isDisposed = false; + state.pty = Pty.start( + state.command ?? Platform.environment['SHELL'] ?? 'bash', + arguments: state.args, + columns: state.terminal.viewWidth, + rows: state.terminal.viewHeight, + environment: Platform.environment, + workingDirectory: Platform.environment['HOME'], + ); + + final command = '${state.pty.executable} ${state.pty.arguments.join(' ')}'; + state.terminal.setTitle(command); + + // Write the command to the terminal + state.terminal.write('[Process: $command]\r\n\n'); + + // Write stdout of the process to the terminal + state.pty.output.cast>().transform(const Utf8Decoder()).listen(state.terminal.write); + + // Write exit code of the process to the terminal + state.pty.exitCode.then((code) async { + state.terminal.write('\n[The process exited with code: $code]\r\n\n'); + state.terminal.setCursorVisibleMode(false); + + int delay = 5; + + /// Count down to closing the terminal + for (int i = 0; i < delay; i++) { + String message = 'Closing terminal session in ${delay - i} seconds...\r'; + state.terminal.write(message); + await Future.delayed(const Duration(seconds: 1)); + } + + /// Close the terminal after [delay] seconds + state.isRunning = false; + dispose(); + }); + + // Write the terminal output to the process + state.terminal.onOutput = (data) { + state.pty.write(const Utf8Encoder().convert(data)); + }; + + // Resize the terminal when the window is resized + state.terminal.onResize = (w, h, pw, ph) { + state.pty.resize(h, w); + }; + } + + void _killProcess() { + state.pty.kill(); + state.isRunning = false; + } + + void dispose() { + /// If the session is already disposed, return null + if (state.isDisposed) return; + + /// 1. Set the session to disposed + if (state.isRunning) _killProcess(); + + // 2. Find a new session to set as the active one + final terminalList = ref.read(terminalSessionListController); + final currentSessionId = ref.read(terminalSessionController); + final currentIndex = terminalList.indexOf(currentSessionId); + if (currentSessionId == state.sessionId) { + // Find a new terminal tab to set as the active one + if (currentIndex > 0) { + // set active terminal to the one immediately to the left + ref.read(terminalSessionController.notifier).setSession(terminalList[currentIndex - 1]); + } else if (terminalList.length > 1) { + // set active terminal to the one immediately to the right + ref.read(terminalSessionController.notifier).setSession(terminalList[currentIndex + 1]); + } else { + // no other sessions available, set active terminal to empty string + ref.read(terminalSessionController.notifier).setSession(''); + } + } + + /// 3. Remove the session from the list of sessions + ref.read(terminalSessionListController.notifier)._remove(state.sessionId); + + /// 4. Remove the session from the profile name counter + if (state._profileName != null) { + ref.read(terminalSessionProfileNameFamilyCounter(state._profileName!).notifier)._removeSession(state.sessionId); + } + } +} + +/// Counter for the number of terminal sessions by profileName - issues and tracks the display name for each session +class TerminalSessionProfileNameFamilyCounter extends FamilyNotifier { + @override + int build(String arg) => 0; + + final List _sessionQueue = []; + + String _addSession(String sessionId) { + state++; + for (int i = 0; i < _sessionQueue.length; i++) { + if (_sessionQueue[i] == null) { + _sessionQueue[i] = sessionId; + return '$arg-${i + 1}'; + } + } + _sessionQueue.add(sessionId); + return '$arg-${_sessionQueue.length}'; + } + + bool _removeSession(String sessionId) { + for (int i = 0; i < _sessionQueue.length; i++) { + if (_sessionQueue[i] == sessionId) { + _sessionQueue[i] = null; + state--; + return true; + } + } + return false; + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/screens/home_screen.dart b/packages/sshnp_gui/lib/src/presentation/screens/home_screen.dart index b034605f8..4676f889e 100644 --- a/packages/sshnp_gui/lib/src/presentation/screens/home_screen.dart +++ b/packages/sshnp_gui/lib/src/presentation/screens/home_screen.dart @@ -1,21 +1,13 @@ -import 'package:at_client_mobile/at_client_mobile.dart'; import 'package:flutter/material.dart'; + import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:go_router/go_router.dart'; -import 'package:sshnoports/sshnp/sshnp.dart'; -import 'package:sshnoports/sshrv/sshrv.dart'; -import 'package:sshnp_gui/src/controllers/minor_providers.dart'; -import 'package:sshnp_gui/src/presentation/widgets/sshnp_result_alert_dialog.dart'; -import 'package:sshnp_gui/src/utils/enum.dart'; - -import '../../controllers/home_screen_controller.dart'; -import '../../utils/app_router.dart'; -import '../../utils/sizes.dart'; -import '../widgets/app_navigation_rail.dart'; -import '../widgets/custom_table_cell.dart'; -import '../widgets/delete_alert_dialog.dart'; +import 'package:sshnp_gui/src/controllers/config_controller.dart'; +import 'package:sshnp_gui/src/presentation/widgets/home_screen_actions/home_screen_actions.dart'; +import 'package:sshnp_gui/src/presentation/widgets/navigation/app_navigation_rail.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_bar/profile_bar.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; // * Once the onboarding process is completed you will be taken to this screen class HomeScreen extends ConsumerStatefulWidget { @@ -26,78 +18,12 @@ class HomeScreen extends ConsumerStatefulWidget { } class _HomeScreenState extends ConsumerState { - @override - void initState() { - WidgetsBinding.instance.addPostFrameCallback((_) async { - await ref.read(homeScreenControllerProvider.notifier).getConfigFiles(); - }); - super.initState(); - } - - Future ssh(SSHNPParams sshnpParams) async { - if (mounted) { - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) => const Center(child: CircularProgressIndicator()), - ); - } - - try { - final sshnp = await SSHNP.fromParams( - sshnpParams, - atClient: AtClientManager.getInstance().atClient, - sshrvGenerator: SSHRV.pureDart, - ); - - await sshnp.init(); - final sshnpResult = await sshnp.run(); - - if (mounted) { - // pop to remove circular progress indicator - context.pop(); - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) => SSHNPResultAlertDialog( - result: sshnpResult.toString(), - title: 'Success', - ), - ); - } - } catch (e) { - if (mounted) { - context.pop(); - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) => SSHNPResultAlertDialog( - result: e.toString(), - title: 'Failed', - ), - ); - } - } - } - - void updateConfigFile(SSHNPParams sshnpParams) { - ref.read(sshnpParamsProvider.notifier).update((state) => sshnpParams); - // change value to 1 to update navigation rail selcted icon. - ref.read(currentNavIndexProvider.notifier).update((state) => AppRoute.newConnection.index - 1); - // Change value to update to trigger the update functionality on the new connection form. - ref.read(configFileWriteStateProvider.notifier).update((state) => ConfigFileWriteState.update); - context.replaceNamed( - AppRoute.newConnection.name, - ); - } - @override Widget build(BuildContext context) { // * Getting the AtClientManager instance to use below final strings = AppLocalizations.of(context)!; - final state = ref.watch(homeScreenControllerProvider); - + final profileNames = ref.watch(configListController); return Scaffold( body: SafeArea( child: Row( @@ -108,107 +34,36 @@ class _HomeScreenState extends ConsumerState { child: Padding( padding: const EdgeInsets.only(left: Sizes.p36, top: Sizes.p21), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - SvgPicture.asset( - 'assets/images/noports_light.svg', + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SvgPicture.asset( + 'assets/images/noports_light.svg', + ), + const HomeScreenActions(), + ], ), gapH24, - Text(strings.availableConnections), - state.isLoading - ? const Center( - child: CircularProgressIndicator(), - ) - : state.value!.isNotEmpty - ? Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Table( - defaultVerticalAlignment: TableCellVerticalAlignment.middle, - columnWidths: const { - 0: IntrinsicColumnWidth(), - 1: IntrinsicColumnWidth(), - 2: IntrinsicColumnWidth(), - 3: IntrinsicColumnWidth(), - 4: IntrinsicColumnWidth(), - 5: IntrinsicColumnWidth(), - 6: FixedColumnWidth(150), - }, - children: [ - TableRow( - decoration: - const BoxDecoration(border: Border(bottom: BorderSide(color: Colors.white))), - children: [ - CustomTableCell.text(text: strings.actions), - CustomTableCell.text( - text: strings.clientAtsign), // todo change this to strings.profileName - CustomTableCell.text(text: strings.sshnpdAtSign), - CustomTableCell.text(text: strings.device), - CustomTableCell.text(text: strings.port), - CustomTableCell.text(text: strings.localPort), - CustomTableCell.text(text: strings.localSshOptions), - ], - ), - ...state.value! - .map( - (e) => TableRow( - children: [ - CustomTableCell( - child: Row( - children: [ - IconButton( - onPressed: () async { - await ssh(e); - }, - icon: const Icon(Icons.connect_without_contact_outlined), - ), - IconButton( - onPressed: () { - // get the index of the config file so it can be updated - ref - .read(sshnpParamsUpdateIndexProvider.notifier) - .update((value) => state.value!.indexOf(e)); - updateConfigFile(e); - }, - icon: const Icon(Icons.edit), - ), - IconButton( - onPressed: () async { - showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext context) => - DeleteAlertDialog(index: state.value!.indexOf(e)), - ); - }, - icon: const Icon(Icons.delete_forever), - ), - ], - )), - CustomTableCell.text(text: e.profileName ?? ''), - CustomTableCell.text(text: e.sshnpdAtSign ?? ''), - CustomTableCell.text(text: e.device), - CustomTableCell.text(text: e.port.toString()), - CustomTableCell.text(text: e.localPort.toString()), - CustomTableCell.text(text: e.localSshOptions.join(',')), - // CustomTableCell( - // child: Row( - // children: [ - // TextButton.icon( - // onPressed: () { - // context.pushNamed(StudentRoute.details.name, params: {'id': e.id}); - // }, - // icon: const Icon(Icons.visibility_outlined), - // label: Text(strings.view)), - // ], - // )) - ], - ), - ) - .toList() - ], - ), - ), - ) - : const Text('No SSHNP Configurations Found') + Text(strings.availableConnections, textScaleFactor: 2), + gapH8, + profileNames.when( + loading: () => const Center( + child: CircularProgressIndicator(), + ), + error: (e, s) => Text(e.toString()), + data: (profiles) { + if (profiles.isEmpty) { + return const Text('No SSHNP Configurations Found'); + } + final sortedProfiles = profiles.toList(); + sortedProfiles.sort(); + return Expanded( + child: ListView( + children: sortedProfiles.map((profileName) => ProfileBar(profileName)).toList(), + ), + ); + }, + ) ]), ), ), @@ -218,3 +73,12 @@ class _HomeScreenState extends ConsumerState { ); } } + +class HomeScreenBodyWrapper extends StatelessWidget { + const HomeScreenBodyWrapper({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container(); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/screens/onboarding_screen.dart b/packages/sshnp_gui/lib/src/presentation/screens/onboarding_screen.dart index 5f4d6bc45..0c07f485b 100644 --- a/packages/sshnp_gui/lib/src/presentation/screens/onboarding_screen.dart +++ b/packages/sshnp_gui/lib/src/presentation/screens/onboarding_screen.dart @@ -8,7 +8,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:go_router/go_router.dart'; import 'package:macos_ui/macos_ui.dart'; import 'package:path_provider/path_provider.dart' show getApplicationSupportDirectory; -import 'package:sshnp_gui/src/utils/app_router.dart'; +import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; class OnboardingScreen extends StatefulWidget { const OnboardingScreen({super.key}); diff --git a/packages/sshnp_gui/lib/src/presentation/screens/new_connection_screen.dart b/packages/sshnp_gui/lib/src/presentation/screens/profile_editor_screen.dart similarity index 74% rename from packages/sshnp_gui/lib/src/presentation/screens/new_connection_screen.dart rename to packages/sshnp_gui/lib/src/presentation/screens/profile_editor_screen.dart index 2058e921c..4514ce0ce 100644 --- a/packages/sshnp_gui/lib/src/presentation/screens/new_connection_screen.dart +++ b/packages/sshnp_gui/lib/src/presentation/screens/profile_editor_screen.dart @@ -1,19 +1,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:sshnp_gui/src/presentation/widgets/new_connection_form.dart'; - -import '../../utils/sizes.dart'; -import '../widgets/app_navigation_rail.dart'; +import 'package:sshnp_gui/src/presentation/widgets/navigation/app_navigation_rail.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_form/profile_form.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; // * Once the onboarding process is completed you will be taken to this screen -class NewConnectionScreen extends StatefulWidget { - const NewConnectionScreen({Key? key}) : super(key: key); +class ProfileEditorScreen extends StatefulWidget { + const ProfileEditorScreen({Key? key}) : super(key: key); @override - State createState() => _NewConnectionScreenState(); + State createState() => _ProfileEditorScreenState(); } -class _NewConnectionScreenState extends State { +class _ProfileEditorScreenState extends State { @override Widget build(BuildContext context) { final strings = AppLocalizations.of(context)!; @@ -32,7 +31,7 @@ class _NewConnectionScreenState extends State { style: Theme.of(context).textTheme.titleMedium, ), gapH10, - const Expanded(child: NewConnectionForm()) + const Expanded(child: ProfileForm()) ]), ), ), diff --git a/packages/sshnp_gui/lib/src/presentation/screens/settings_screen.dart b/packages/sshnp_gui/lib/src/presentation/screens/settings_screen.dart index b5ac56a7d..de1ee0b2a 100644 --- a/packages/sshnp_gui/lib/src/presentation/screens/settings_screen.dart +++ b/packages/sshnp_gui/lib/src/presentation/screens/settings_screen.dart @@ -1,15 +1,8 @@ -import 'package:at_backupkey_flutter/at_backupkey_flutter.dart'; -import 'package:at_contacts_flutter/services/contact_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:sshnp_gui/src/presentation/widgets/app_navigation_rail.dart'; -import 'package:sshnp_gui/src/presentation/widgets/reset_app_button.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../../repository/navigation_service.dart'; -import '../../utils/sizes.dart'; -import '../widgets/settings_button.dart'; -import '../widgets/switch_atsign.dart'; +import 'package:sshnp_gui/src/presentation/widgets/navigation/app_navigation_rail.dart'; +import 'package:sshnp_gui/src/presentation/widgets/settings_actions/settings_actions.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({Key? key}) : super(key: key); @@ -29,9 +22,7 @@ class SettingsScreen extends StatelessWidget { Expanded( child: Padding( padding: const EdgeInsets.only(left: Sizes.p36, top: Sizes.p21), - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, + child: ListView( children: [ Padding( padding: const EdgeInsets.symmetric(vertical: Sizes.p20), @@ -40,82 +31,20 @@ class SettingsScreen extends StatelessWidget { style: Theme.of(context).textTheme.headlineMedium, ), ), - // Text( - // ContactService().currentAtsign, - // style: Theme.of(context).textTheme.bodyLarge, - // ), - // Text( - // ContactService().loggedInUserDetails!.tags!['name'] ?? '', - // style: Theme.of(context).textTheme.displaySmall, - // ), - // const SizedBox( - // height: 30, - // ), - // SettingsButton( - // icon: Icons.block_outlined, - // title: 'Blocked Contacts', - // onTap: () { - // Navigator.of(context).pushNamed(CustomBlockedScreen.routeName); - // }, - // ), const SizedBox( height: 59, ), - SettingsButton( - icon: Icons.bookmark_outline, - title: strings.backupYourKeys, - onTap: () { - BackupKeyWidget(atsign: ContactService().currentAtsign).showBackupDialog(context); - }, - ), + const Center(child: SettingsBackupKeyAction()), gapH16, - SettingsButton( - icon: Icons.logout_rounded, - title: strings.switchAtsign, - onTap: () async { - await showModalBottomSheet( - context: NavigationService.navKey.currentContext!, - builder: (context) => const AtSignBottomSheet()); - }, - ), + const Center(child: SettingsSwitchAtsignAction()), gapH16, - const ResetAppButton(), + const Center(child: SettingsResetAppAction()), gapH36, - SettingsButton( - icon: Icons.help_center_outlined, - title: strings.faq, - onTap: () async { - final Uri url = Uri.parse('https://atsign.com/faqs/'); - if (!await launchUrl(url)) { - throw Exception('Could not launch $url'); - } - }, - ), + const Center(child: SettingsFaqAction()), gapH16, - SettingsButton( - icon: Icons.forum_outlined, - title: strings.contactUs, - onTap: () async { - Uri emailUri = Uri( - scheme: 'mailto', - path: 'atDataBrowser@atsign.com', - ); - if (!await launchUrl(emailUri)) { - throw Exception('Could not launch $emailUri'); - } - }, - ), + const Center(child: SettingsContactAction()), gapH16, - SettingsButton( - icon: Icons.account_balance_wallet_outlined, - title: strings.privacyPolicy, - onTap: () async { - final Uri url = Uri.parse('https://atsign.com/apps/atdatabrowser-privacy-policy/'); - if (!await launchUrl(url)) { - throw Exception('Could not launch $url'); - } - }, - ), + const Center(child: SettingsPrivacyPolicyAction()), ], ), ), diff --git a/packages/sshnp_gui/lib/src/presentation/screens/terminal_screen.dart b/packages/sshnp_gui/lib/src/presentation/screens/terminal_screen.dart index 3ad3e9e41..75286b5a5 100644 --- a/packages/sshnp_gui/lib/src/presentation/screens/terminal_screen.dart +++ b/packages/sshnp_gui/lib/src/presentation/screens/terminal_screen.dart @@ -1,18 +1,13 @@ -import 'dart:convert'; -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_pty/flutter_pty.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:sshnp_gui/src/controllers/minor_providers.dart'; +import 'package:sshnp_gui/src/controllers/terminal_session_controller.dart'; +import 'package:sshnp_gui/src/presentation/widgets/navigation/app_navigation_rail.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; import 'package:xterm/xterm.dart'; -import '../../controllers/home_screen_controller.dart'; -import '../../utils/sizes.dart'; -import '../widgets/app_navigation_rail.dart'; - // * Once the onboarding process is completed you will be taken to this screen class TerminalScreen extends ConsumerStatefulWidget { const TerminalScreen({Key? key}) : super(key: key); @@ -21,59 +16,39 @@ class TerminalScreen extends ConsumerStatefulWidget { ConsumerState createState() => _TerminalScreenState(); } -class _TerminalScreenState extends ConsumerState { - var terminal = Terminal(); +class _TerminalScreenState extends ConsumerState with TickerProviderStateMixin { final terminalController = TerminalController(); late final Pty pty; - @override void initState() { super.initState(); + final sessionId = ref.read(terminalSessionController); + + final sessionController = ref.read(terminalSessionFamilyController(sessionId).notifier); WidgetsBinding.instance.endOfFrame.then((value) { - if (mounted) _startPty(); + sessionController.startProcess(); }); } - void _startPty({String? command, List? args}) { - pty = Pty.start( - command ?? Platform.environment['SHELL'] ?? 'bash', - arguments: args ?? [], - columns: terminal.viewWidth, - rows: terminal.viewHeight, - ); - - pty.output.cast>().transform(const Utf8Decoder()).listen(terminal.write); - - pty.exitCode.then( - (code) => terminal.write('the process exited with code $code'), - ); - - terminal.onOutput = (data) { - pty.write(const Utf8Encoder().convert(data)); - }; - - terminal.onResize = (w, h, pw, ph) { - pty.resize(h, w); - }; - - // write ssh result command to terminal - pty.write(const Utf8Encoder().convert(ref.watch(terminalSSHCommandProvider))); - // reset provider - ref.read(terminalSSHCommandProvider.notifier).update((state) => ''); - } - @override void dispose() { terminalController.dispose(); super.dispose(); } + void closeSession(String sessionId) { + // Remove the session from the list of sessions + final controller = ref.read(terminalSessionFamilyController(sessionId).notifier); + controller.dispose(); + } + @override Widget build(BuildContext context) { - // * Getting the AtClientManager instance to use below - final strings = AppLocalizations.of(context)!; - final state = ref.watch(homeScreenControllerProvider); + final terminalList = ref.watch(terminalSessionListController); + final currentSessionId = ref.watch(terminalSessionController); + final int currentIndex = (terminalList.isEmpty) ? 0 : terminalList.indexOf(currentSessionId); + final tabController = TabController(initialIndex: currentIndex, length: terminalList.length, vsync: this); return Scaffold( body: SafeArea( @@ -84,19 +59,57 @@ class _TerminalScreenState extends ConsumerState { Expanded( child: Padding( padding: const EdgeInsets.only(left: Sizes.p36, top: Sizes.p21, right: Sizes.p36), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - SvgPicture.asset( - 'assets/images/noports_light.svg', - ), - gapH24, - Expanded( - child: TerminalView( - terminal, - controller: terminalController, - autofocus: true, + child: DefaultTabController( + length: terminalList.length, + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SvgPicture.asset( + 'assets/images/noports_light.svg', ), - ), - ]), + gapH24, + if (terminalList.isEmpty) Text(strings.noTerminalSessions, textScaleFactor: 2), + if (terminalList.isEmpty) Text(strings.noTerminalSessionsHelp), + if (terminalList.isNotEmpty) + TabBar( + controller: tabController, + isScrollable: true, + onTap: (index) { + ref.read(terminalSessionController.notifier).setSession(terminalList[index]); + }, + tabs: terminalList.map((String sessionId) { + final displayName = ref.read(terminalSessionFamilyController(sessionId).notifier).displayName; + return Tab( + // text: e, + key: Key('terminal-tab-$sessionId'), + child: Row( + children: [ + Text(displayName), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => closeSession(sessionId), + ) + ], + ), + ); + }).toList(), + ), + if (terminalList.isNotEmpty) gapH24, + if (terminalList.isNotEmpty) + Expanded( + child: TabBarView( + controller: tabController, + children: terminalList.map((String sessionId) { + return TerminalView( + key: Key('terminal-view-$sessionId'), + ref.watch(terminalSessionFamilyController(sessionId)).terminal, + controller: terminalController, + autofocus: true, + autoResize: true, + ); + }).toList(), + ), + ), + ]), + ), ), ), ], diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/app_navigation_rail.dart b/packages/sshnp_gui/lib/src/presentation/widgets/app_navigation_rail.dart deleted file mode 100644 index d5aa3576f..000000000 --- a/packages/sshnp_gui/lib/src/presentation/widgets/app_navigation_rail.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:go_router/go_router.dart'; -import 'package:sshnoports/sshnp/sshnp.dart'; - -import '../../controllers/minor_providers.dart'; -import '../../utils/app_router.dart'; - -class AppNavigationRail extends ConsumerWidget { - const AppNavigationRail({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final currentIndex = ref.watch(currentNavIndexProvider); - - return NavigationRail( - destinations: [ - NavigationRailDestination( - icon: currentIndex == 0 - ? SvgPicture.asset('assets/images/nav_icons/home_selected.svg') - : SvgPicture.asset( - 'assets/images/nav_icons/home_unselected.svg', - ), - label: const Text(''), - ), - NavigationRailDestination( - icon: currentIndex == 1 - ? SvgPicture.asset('assets/images/nav_icons/new_selected.svg') - : SvgPicture.asset('assets/images/nav_icons/new_unselected.svg'), - label: const Text(''), - ), - NavigationRailDestination( - icon: currentIndex == 2 - ? SvgPicture.asset('assets/images/nav_icons/pican_selected.svg') - : SvgPicture.asset('assets/images/nav_icons/pican_unselected.svg'), - label: const Text(''), - ), - NavigationRailDestination( - icon: currentIndex == 3 - ? SvgPicture.asset('assets/images/nav_icons/settings_selected.svg') - : SvgPicture.asset('assets/images/nav_icons/settings_unselected.svg'), - label: const Text(''), - ), - ], - selectedIndex: ref.watch(currentNavIndexProvider), - onDestinationSelected: (int selectedIndex) { - ref.read(currentNavIndexProvider.notifier).update((state) => selectedIndex); - - switch (selectedIndex) { - case 0: - context.goNamed(AppRoute.home.name); - break; - case 1: - // set value to default create to trigger the create functionality on - ref - .read(sshnpParamsProvider.notifier) - .update((state) => SSHNPParams(clientAtSign: '', sshnpdAtSign: '', host: '', legacyDaemon: true)); - - context.goNamed(AppRoute.newConnection.name); - break; - case 2: - context.goNamed(AppRoute.terminal.name); - break; - case 3: - context.goNamed(AppRoute.settings.name); - break; - } - }); - } -} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/custom_table_cell.dart b/packages/sshnp_gui/lib/src/presentation/widgets/custom_table_cell.dart deleted file mode 100644 index b1c5bb248..000000000 --- a/packages/sshnp_gui/lib/src/presentation/widgets/custom_table_cell.dart +++ /dev/null @@ -1,30 +0,0 @@ -import 'package:flutter/material.dart'; - -class CustomTableCell extends StatelessWidget { - const CustomTableCell({ - required this.child, - this.text = '', - super.key, - }); - - const CustomTableCell.text({ - super.key, - this.child = const SizedBox(), - required this.text, - }); - - final Widget child; - final String text; - - @override - Widget build(BuildContext context) { - return TableCell( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Center( - child: text.isNotEmpty ? Text(text) : child, - ), - ), - ); - } -} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_action_callbacks.dart b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_action_callbacks.dart new file mode 100644 index 000000000..50cc7721e --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_action_callbacks.dart @@ -0,0 +1,47 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sshnoports/sshnp/config_repository/config_file_repository.dart'; +import 'package:sshnoports/sshnp/sshnp.dart'; +import 'package:sshnp_gui/src/controllers/config_controller.dart'; +import 'package:sshnp_gui/src/presentation/widgets/home_screen_actions/home_screen_import_dialog.dart'; +import 'package:sshnp_gui/src/presentation/widgets/utility/custom_snack_bar.dart'; +import 'package:sshnp_gui/src/utility/constants.dart'; + +class HomeScreenActionCallbacks { + static Future import(WidgetRef ref, BuildContext context) async { + if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) { + return _importDesktop(ref, context); + } + CustomSnackBar.error(content: 'Unable to import profile:\nUnsupported platform'); + } + + static Future _importDesktop(WidgetRef ref, BuildContext context) async { + try { + final XFile? file = await openFile(acceptedTypeGroups: [dotEnvTypeGroup]); + if (file == null) return; + if (context.mounted) { + String initialName = ConfigFileRepository.toProfileName(file.path); + String? profileName = await _getProfileNameFromUser(context, initialName: initialName); + if (profileName == null) return; + if (profileName.isEmpty) profileName = initialName; + final lines = (await file.readAsString()).split('\n'); + ref.read(configFamilyController(profileName).notifier).putConfig(SSHNPParams.fromConfig(profileName, lines)); + } + } catch (e) { + CustomSnackBar.error(content: 'Unable to import profile:\n${e.toString()}'); + } + } + + static Future _getProfileNameFromUser(BuildContext context, {String? initialName}) async { + String? profileName; + setProfileName(String? p) => profileName = p; + await showDialog( + context: context, + builder: (_) => HomeScreenImportDialog(setProfileName, initialName: initialName), + ); + return profileName; + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_actions.dart b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_actions.dart new file mode 100644 index 000000000..862e53412 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_actions.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:sshnp_gui/src/presentation/widgets/home_screen_actions/home_screen_menu_button.dart'; +import 'package:sshnp_gui/src/presentation/widgets/home_screen_actions/new_profile_action.dart'; + +class HomeScreenActions extends StatelessWidget { + const HomeScreenActions({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Row( + children: [NewProfileAction(), HomeScreenMenuButton()], + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_import_dialog.dart b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_import_dialog.dart new file mode 100644 index 000000000..956b3082d --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_import_dialog.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_form/custom_text_form_field.dart'; +import 'package:sshnp_gui/src/utility/form_validator.dart'; + +class HomeScreenImportDialog extends StatefulWidget { + final void Function(String?) setValue; + + final String? initialName; + const HomeScreenImportDialog(this.setValue, {this.initialName, Key? key}) + : super(key: key); + + @override + State createState() => _HomeScreenImportDialogState(); +} + +class _HomeScreenImportDialogState extends State { + final GlobalKey _formkey = GlobalKey(); + String? result; + + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + return AlertDialog( + title: Text(strings.importProfile), + content: Form( + key: _formkey, + child: CustomTextFormField( + initialValue: widget.initialName, + labelText: strings.profileName, + onChanged: (value) { + result = value; + }, + validator: FormValidator.validateProfileNameField, + ), + ), + actions: [ + OutlinedButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text(strings.cancelButton, + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith(decoration: TextDecoration.underline)), + ), + ElevatedButton( + onPressed: () { + if (_formkey.currentState!.validate()) { + widget.setValue(result); + Navigator.of(context).pop(); + } + }, + style: Theme.of(context).elevatedButtonTheme.style!.copyWith( + backgroundColor: MaterialStateProperty.all(Colors.black), + ), + child: Text( + strings.submit, + style: Theme.of(context) + .textTheme + .bodyLarge! + .copyWith(fontWeight: FontWeight.w700, color: Colors.white), + ), + ) + ], + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_menu_button.dart b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_menu_button.dart new file mode 100644 index 000000000..2192dd363 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/home_screen_menu_button.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:sshnp_gui/src/presentation/widgets/home_screen_actions/home_screen_action_callbacks.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; + +class HomeScreenMenuButton extends ConsumerStatefulWidget { + const HomeScreenMenuButton({Key? key}) : super(key: key); + + @override + ConsumerState createState() => _ProfileMenuBarState(); +} + +class _ProfileMenuBarState extends ConsumerState { + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + return PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + child: ProfileMenuItem(const Icon(Icons.upload), strings.import), + onTap: () => HomeScreenActionCallbacks.import(ref, context), + ), + ], + padding: EdgeInsets.zero, + ); + } +} + +class ProfileMenuItem extends StatelessWidget { + final Widget icon; + final String text; + const ProfileMenuItem(this.icon, this.text, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + icon, + gapW12, + Text(text), + ], + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/new_profile_action.dart b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/new_profile_action.dart new file mode 100644 index 000000000..1ff376637 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/home_screen_actions/new_profile_action.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sshnp_gui/src/controllers/config_controller.dart'; +import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; + +class NewProfileAction extends ConsumerStatefulWidget { + const NewProfileAction({Key? key}) : super(key: key); + + @override + ConsumerState createState() => _NewProfileActionState(); +} + +class _NewProfileActionState extends ConsumerState { + void onPressed() { + // Change value to update to trigger the update functionality on the new connection form. + ref.watch(currentConfigController.notifier).setState( + CurrentConfigState( + profileName: '', + configFileWriteState: ConfigFileWriteState.create, + ), + ); + context.replaceNamed( + AppRoute.profileForm.name, + ); + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () { + onPressed(); + }, + icon: const Icon(Icons.add), + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/navigation/app_navigation_rail.dart b/packages/sshnp_gui/lib/src/presentation/widgets/navigation/app_navigation_rail.dart new file mode 100644 index 000000000..275bba6d3 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/navigation/app_navigation_rail.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; +import 'package:sshnp_gui/src/controllers/navigation_rail_controller.dart'; + +class AppNavigationRail extends ConsumerWidget { + const AppNavigationRail({super.key}); + + static var activatedIcons = [ + SvgPicture.asset('assets/images/nav_icons/home_selected.svg'), + SvgPicture.asset('assets/images/nav_icons/pican_selected.svg'), + SvgPicture.asset('assets/images/nav_icons/settings_selected.svg') + ]; + + static var deactivatedIcons = [ + SvgPicture.asset('assets/images/nav_icons/home_unselected.svg'), + SvgPicture.asset('assets/images/nav_icons/pican_unselected.svg'), + SvgPicture.asset('assets/images/nav_icons/settings_unselected.svg'), + ]; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = ref.watch(navigationRailController.notifier); + final currentIndex = controller.getCurrentIndex(); + + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height, + ), + child: IntrinsicHeight( + child: NavigationRail( + destinations: controller.routes + .map( + (AppRoute route) => NavigationRailDestination( + icon: (controller.isCurrentIndex(route)) + ? activatedIcons[controller.indexOf(route)] + : deactivatedIcons[controller.indexOf(route)], + label: const Text(''), + ), + ) + .toList(), + selectedIndex: currentIndex, + onDestinationSelected: (int selectedIndex) { + controller.setIndex(selectedIndex); + context.goNamed(controller.getCurrentRoute().name); + }, + ), + ), + ), + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/new_connection_form.dart b/packages/sshnp_gui/lib/src/presentation/widgets/new_connection_form.dart deleted file mode 100644 index d0be24d13..000000000 --- a/packages/sshnp_gui/lib/src/presentation/widgets/new_connection_form.dart +++ /dev/null @@ -1,259 +0,0 @@ -import 'dart:developer'; - -import 'package:at_client_mobile/at_client_mobile.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:sshnoports/sshnp/sshnp.dart'; -import 'package:sshnp_gui/src/controllers/home_screen_controller.dart'; -import 'package:sshnp_gui/src/controllers/minor_providers.dart'; -import 'package:sshnp_gui/src/utils/app_router.dart'; -import 'package:sshnp_gui/src/utils/enum.dart'; -import 'package:sshnp_gui/src/utils/validator.dart'; - -import '../../utils/sizes.dart'; -import 'custom_text_form_field.dart'; - -class NewConnectionForm extends ConsumerStatefulWidget { - const NewConnectionForm({super.key}); - - @override - ConsumerState createState() => _NewConnectionFormState(); -} - -class _NewConnectionFormState extends ConsumerState { - final GlobalKey _formkey = GlobalKey(); - late String? sshnpdAtSign; - late String? host; - late String? profileName; - - /// Optional Arguments - late String device; - late int port; - late int localPort; - late String sendSshPublicKey; - late List localSshOptions; - late bool verbose; - late bool rsa; - late String? remoteUsername; - late String? atKeysFilePath; - late String rootDomain; - late bool listDevices; - late bool legacyDaemon; - @override - void initState() { - super.initState(); - - final oldConfig = ref.read(sshnpParamsProvider); - - sshnpdAtSign = oldConfig.sshnpdAtSign; - host = oldConfig.host; - profileName = oldConfig.profileName; - - /// Optional Arguments - device = oldConfig.device; - port = oldConfig.port; - localPort = oldConfig.localPort; - sendSshPublicKey = oldConfig.sendSshPublicKey; - localSshOptions = oldConfig.localSshOptions; - verbose = oldConfig.verbose; - rsa = oldConfig.rsa; - remoteUsername = oldConfig.remoteUsername; - atKeysFilePath = oldConfig.atKeysFilePath; - rootDomain = oldConfig.rootDomain; - listDevices = oldConfig.listDevices; - legacyDaemon = oldConfig.legacyDaemon; - } - - void createNewConnection() async { - if (_formkey.currentState!.validate()) { - _formkey.currentState!.save(); - - final sshnpParams = SSHNPParams( - profileName: 'default_profile', - clientAtSign: AtClientManager.getInstance().atClient.getCurrentAtSign(), - sshnpdAtSign: sshnpdAtSign, - host: host, - device: device, - port: port, - localPort: localPort, - sendSshPublicKey: sendSshPublicKey, - localSshOptions: localSshOptions, - verbose: verbose, - rsa: rsa, - remoteUsername: remoteUsername, - atKeysFilePath: atKeysFilePath, - rootDomain: rootDomain, - listDevices: listDevices, - legacyDaemon: legacyDaemon); - switch (ref.read(configFileWriteStateProvider)) { - case ConfigFileWriteState.create: - await ref.read(homeScreenControllerProvider.notifier).createConfigFile(sshnpParams); - break; - case ConfigFileWriteState.update: - log('update_worked'); - await ref.read(homeScreenControllerProvider.notifier).updateConfigFile(sshnpParams: sshnpParams); - // set value to default create so trigger the create functionality on - ref.read(configFileWriteStateProvider.notifier).update((state) => ConfigFileWriteState.create); - break; - } - // Reset value to default value. - ref - .read(sshnpParamsProvider.notifier) - .update((state) => SSHNPParams(clientAtSign: '', sshnpdAtSign: '', host: '', legacyDaemon: true)); - if (context.mounted) { - ref.read(currentNavIndexProvider.notifier).update((state) => AppRoute.home.index - 1); - context.pushReplacementNamed(AppRoute.home.name); - } - } - } - - @override - Widget build(BuildContext context) { - final strings = AppLocalizations.of(context)!; - return SingleChildScrollView( - child: Form( - key: _formkey, - child: Row( - children: [ - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // TODO @CurtlyCritchlow - // * remove clientAtSign from the form (if clientAtsign is null then use the AtClient.getCurrentAtSign) - // * add profileName to the form - CustomTextFormField( - initialValue: profileName, - labelText: strings.profileName, - onSaved: (value) => profileName = value!, - validator: Validator.validateRequiredField, - ), - gapH10, - CustomTextFormField( - initialValue: host, - labelText: strings.host, - onSaved: (value) => host = value, - validator: Validator.validateRequiredField, - ), - gapH10, - CustomTextFormField( - initialValue: port.toString(), - labelText: strings.port, - onSaved: (value) => port = int.parse(value!), - validator: Validator.validateRequiredField, - ), - gapH10, - CustomTextFormField( - initialValue: sendSshPublicKey, - labelText: strings.sendSshPublicKey, - onSaved: (value) => sendSshPublicKey = value!, - validator: Validator.validateRequiredField, - ), - gapH10, - Row( - children: [ - Text(strings.verbose), - gapW12, - Switch( - value: verbose, - onChanged: (newValue) { - setState(() { - verbose = newValue; - }); - }), - ], - ), - gapH10, - CustomTextFormField( - initialValue: remoteUsername, - labelText: strings.remoteUserName, - onSaved: (value) => remoteUsername = value!, - ), - gapH10, - CustomTextFormField( - initialValue: rootDomain, - labelText: strings.rootDomain, - onSaved: (value) => rootDomain = value!, - ), - gapH20, - // TODO the edit screen also says "add", can we change the wording to be dynamic, or use "submit" - ElevatedButton( - onPressed: createNewConnection, - child: Text(strings.add), - ), - ]), - gapW12, - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - CustomTextFormField( - initialValue: sshnpdAtSign, - labelText: strings.sshnpdAtSign, - onSaved: (value) => sshnpdAtSign = value, - validator: Validator.validateAtsignField, - ), - gapH10, - CustomTextFormField( - initialValue: device, - labelText: strings.device, - onSaved: (value) => device = value!, - ), - gapH10, - CustomTextFormField( - initialValue: localPort.toString(), - labelText: strings.localPort, - onSaved: (value) => localPort = int.parse(value!), - ), - gapH10, - // TODO add a note that says multiple options can be specified by separating them with a comma. - CustomTextFormField( - initialValue: localSshOptions.join(','), - labelText: strings.localSshOptions, - onSaved: (value) => localSshOptions = value!.split(','), - ), - gapH10, - Row( - children: [ - Text(strings.rsa), - gapW12, - Switch( - value: rsa, - onChanged: (newValue) { - setState(() { - rsa = newValue; - }); - }), - ], - ), - gapH10, - CustomTextFormField( - initialValue: atKeysFilePath, - labelText: strings.atKeysFilePath, - onSaved: (value) => atKeysFilePath = value, - ), - gapH10, - // TODO remove listDevices from the form - Row( - children: [ - Text(strings.listDevices), - gapW12, - Switch( - value: listDevices, - onChanged: (newValue) { - setState(() { - listDevices = newValue; - }); - }), - ], - ), - gapH20, - TextButton( - onPressed: () { - ref.read(currentNavIndexProvider.notifier).update((state) => AppRoute.home.index - 1); - context.pushReplacementNamed(AppRoute.home.name); - }, - child: Text(strings.cancel)) - ]), - ], - ), - ), - ); - } -} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_action_button.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_action_button.dart new file mode 100644 index 000000000..ff8f1e463 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_action_button.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class ProfileActionButton extends StatelessWidget { + final void Function() onPressed; + final Widget icon; + const ProfileActionButton({ + required this.onPressed, + required this.icon, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + icon: icon, + ); + } +} + + diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_action_callbacks.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_action_callbacks.dart new file mode 100644 index 000000000..bfeac0c08 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_action_callbacks.dart @@ -0,0 +1,70 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sshnoports/common/utils.dart'; +import 'package:sshnoports/sshnp/config_repository/config_file_repository.dart'; +import 'package:sshnp_gui/src/controllers/config_controller.dart'; +import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_delete_dialog.dart'; +import 'package:sshnp_gui/src/presentation/widgets/utility/custom_snack_bar.dart'; +import 'package:path/path.dart' as path; +import 'package:sshnp_gui/src/utility/constants.dart'; + +class ProfileActionCallbacks { + static void edit(WidgetRef ref, BuildContext context, String profileName) { + // Change value to update to trigger the update functionality on the new connection form. + ref.watch(currentConfigController.notifier).setState( + CurrentConfigState( + profileName: profileName, + configFileWriteState: ConfigFileWriteState.update, + ), + ); + context.replaceNamed( + AppRoute.profileForm.name, + ); + } + + static void delete(BuildContext context, String profileName) async { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => ProfileDeleteDialog(profileName: profileName), + ); + } + + static Future export(WidgetRef ref, BuildContext context, String profileName) async { + if (Platform.isMacOS || Platform.isLinux || Platform.isWindows) { + return _exportDesktop(ref, context, profileName); + } + CustomSnackBar.error(content: 'Unable to export profile:\nUnsupported platform'); + } + + static Future _exportDesktop(WidgetRef ref, BuildContext context, String profileName) async { + try { + final suggestedName = ConfigFileRepository.fromProfileName(profileName, basenameOnly: true); + final initialDirectory = getDefaultSshnpConfigDirectory(getHomeDirectory(throwIfNull: true)!); + + final FileSaveLocation? saveLocation = await getSaveLocation( + suggestedName: suggestedName, + initialDirectory: initialDirectory, + acceptedTypeGroups: [dotEnvTypeGroup], + ); + if (saveLocation == null) return; + final params = ref.read(configFamilyController(profileName)); + final fileData = Uint8List.fromList(params.requireValue.toConfig().codeUnits); + final XFile textFile = XFile.fromData( + fileData, + mimeType: dotEnvMimeType, + name: path.basename(saveLocation.path), + ); + + await textFile.saveTo(saveLocation.path); + } catch (e) { + CustomSnackBar.error(content: 'Unable to export profile:\n${e.toString()}'); + } + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_actions.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_actions.dart new file mode 100644 index 000000000..9a4e71ac6 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_actions.dart @@ -0,0 +1,7 @@ +export 'profile_action_button.dart'; +export 'profile_action_callbacks.dart'; +export 'profile_delete_action.dart'; +export 'profile_delete_dialog.dart'; +export 'profile_menu_button.dart'; +export 'profile_run_action.dart'; +export 'profile_terminal_action.dart'; diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_delete_action.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_delete_action.dart new file mode 100644 index 000000000..3615577aa --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_delete_action.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_action_callbacks.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_action_button.dart'; + +class ProfileDeleteAction extends StatelessWidget { + final String profileName; + final bool menuItem; + const ProfileDeleteAction(this.profileName, {this.menuItem = false, Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ProfileActionButton( + onPressed: () => ProfileActionCallbacks.delete(context, profileName), + icon: const Icon(Icons.delete_forever), + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/delete_alert_dialog.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_delete_dialog.dart similarity index 54% rename from packages/sshnp_gui/lib/src/presentation/widgets/delete_alert_dialog.dart rename to packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_delete_dialog.dart index a6e4dda5f..53f1539aa 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/delete_alert_dialog.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_delete_dialog.dart @@ -1,15 +1,12 @@ -import 'dart:developer'; - import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:sshnp_gui/src/controllers/home_screen_controller.dart'; - -import '../../utils/sizes.dart'; +import 'package:sshnp_gui/src/controllers/config_controller.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -class DeleteAlertDialog extends ConsumerWidget { - const DeleteAlertDialog({required this.index, super.key}); - final int index; +class ProfileDeleteDialog extends ConsumerWidget { + const ProfileDeleteDialog({required this.profileName, super.key}); + final String profileName; @override Widget build( @@ -17,8 +14,6 @@ class DeleteAlertDialog extends ConsumerWidget { WidgetRef ref, ) { final strings = AppLocalizations.of(context)!; - final data = ref.watch(homeScreenControllerProvider); - log(index.toString()); return Padding( padding: const EdgeInsets.only(left: 0), @@ -52,25 +47,19 @@ class DeleteAlertDialog extends ConsumerWidget { style: Theme.of(context).textTheme.bodyLarge!.copyWith(decoration: TextDecoration.underline)), ), ElevatedButton( - onPressed: () async { - await ref.read(homeScreenControllerProvider.notifier).delete(index); - - if (context.mounted) Navigator.of(context).pop(); - }, - style: Theme.of(context).elevatedButtonTheme.style!.copyWith( - backgroundColor: MaterialStateProperty.all(Colors.black), - ), - child: !data.isLoading - ? Text( - strings.deleteButton, - style: Theme.of(context) - .textTheme - .bodyLarge! - .copyWith(fontWeight: FontWeight.w700, color: Colors.white), - ) - : const CircularProgressIndicator( - color: Colors.white, - )), + onPressed: () async { + await ref.read(configFamilyController(profileName).notifier).deleteConfig(context: context); + if (context.mounted) Navigator.of(context).pop(); + }, + style: Theme.of(context).elevatedButtonTheme.style!.copyWith( + backgroundColor: MaterialStateProperty.all(Colors.black), + ), + child: Text( + strings.deleteButton, + style: + Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w700, color: Colors.white), + ), + ) ], ), ), diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_menu_button.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_menu_button.dart new file mode 100644 index 000000000..ce28e53bf --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_menu_button.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_action_callbacks.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; + +class ProfileMenuButton extends ConsumerStatefulWidget { + final String profileName; + const ProfileMenuButton(this.profileName, {Key? key}) : super(key: key); + + @override + ConsumerState createState() => _ProfileMenuBarState(); +} + +class _ProfileMenuBarState extends ConsumerState { + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + return PopupMenuButton( + itemBuilder: (context) => [ + PopupMenuItem( + child: ProfileMenuItem(const Icon(Icons.download), strings.export), + onTap: () => ProfileActionCallbacks.export(ref, context, widget.profileName), + ), + PopupMenuItem( + child: ProfileMenuItem(const Icon(Icons.edit), strings.edit), + onTap: () => ProfileActionCallbacks.edit(ref, context, widget.profileName), + ), + PopupMenuItem( + child: const ProfileMenuItem(Icon(Icons.delete_forever), 'Delete'), + onTap: () => ProfileActionCallbacks.delete(context, widget.profileName), + ), + ], + padding: EdgeInsets.zero, + ); + } +} + +class ProfileMenuItem extends StatelessWidget { + final Widget icon; + final String text; + const ProfileMenuItem(this.icon, this.text, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + icon, + gapW12, + Text(text), + ], + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_run_action.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_run_action.dart new file mode 100644 index 000000000..0a2da11af --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_run_action.dart @@ -0,0 +1,110 @@ +import 'dart:io'; + +import 'package:at_client_mobile/at_client_mobile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:socket_connector/socket_connector.dart'; +import 'package:sshnoports/sshnp/sshnp.dart'; +import 'package:sshnoports/sshrv/sshrv.dart'; +import 'package:sshnp_gui/src/controllers/background_session_controller.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_action_button.dart'; +import 'package:sshnp_gui/src/presentation/widgets/utility/custom_snack_bar.dart'; + +class ProfileRunAction extends ConsumerStatefulWidget { + final SSHNPParams params; + const ProfileRunAction(this.params, {Key? key}) : super(key: key); + + @override + ConsumerState createState() => _ProfileRunActionState(); +} + +class _ProfileRunActionState extends ConsumerState { + SSHNP? sshnp; + SSHNPResult? sshnpResult; + + @override + void initState() { + super.initState(); + } + + Future onStart() async { + ref.read(backgroundSessionFamilyController(widget.params.profileName!).notifier).start(); + try { + SSHNPParams params = SSHNPParams.merge( + widget.params, + SSHNPPartialParams( + idleTimeout: 120, // 120 / 60 = 2 minutes + addForwardsToTunnel: true, + legacyDaemon: false, + sshClient: 'pure-dart', + ), + ); + + sshnp = await SSHNP.fromParams( + params, + atClient: AtClientManager.getInstance().atClient, + sshrvGenerator: SSHRV.pureDart, + ); + + await sshnp!.init(); + sshnpResult = await sshnp!.run(); + + if (sshnpResult is SSHNPFailed) { + throw sshnpResult!; + } + ref.read(backgroundSessionFamilyController(widget.params.profileName!).notifier).endStartUp(); + } catch (e) { + Future stop = onStop(); + if (mounted) { + CustomSnackBar.error(content: e.toString()); + } + await stop; + } + } + + Future onStop() async { + if (sshnpResult is SSHCommand) { + (sshnpResult as SSHCommand).sshProcess?.kill(); // DirectSSHViaExec + (sshnpResult as SSHCommand).sshClient?.close(); // DirectSSHViaClient + var sshrvResult = await (sshnpResult as SSHCommand).sshrvResult; + if (sshrvResult is Process) sshrvResult.kill(); // SSHRV via local binary + if (sshrvResult is SocketConnector) sshrvResult.close(); // SSHRV via pure dart + } + ref.read(backgroundSessionFamilyController(widget.params.profileName!).notifier).stop(); + } + + static Widget getIconFromStatus(BackgroundSessionStatus status, BuildContext context) { + switch (status) { + case BackgroundSessionStatus.stopped: + return const Icon(Icons.play_arrow); + case BackgroundSessionStatus.running: + return const Icon(Icons.stop); + case BackgroundSessionStatus.loading: + return SizedBox( + width: IconTheme.of(context).size, + height: IconTheme.of(context).size, + child: const CircularProgressIndicator(), + ); + } + } + + @override + Widget build(BuildContext context) { + final status = ref.watch(backgroundSessionFamilyController(widget.params.profileName!)); + return ProfileActionButton( + onPressed: () async { + switch (status) { + case BackgroundSessionStatus.stopped: + await onStart(); + break; + case BackgroundSessionStatus.loading: + break; + case BackgroundSessionStatus.running: + await onStop(); + break; + } + }, + icon: getIconFromStatus(status, context), + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_terminal_action.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_terminal_action.dart new file mode 100644 index 000000000..600830cc5 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_actions/profile_terminal_action.dart @@ -0,0 +1,89 @@ +import 'package:at_client_mobile/at_client_mobile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sshnoports/sshnp/sshnp.dart'; +import 'package:sshnoports/sshrv/sshrv.dart'; +import 'package:sshnp_gui/src/controllers/navigation_rail_controller.dart'; +import 'package:sshnp_gui/src/controllers/terminal_session_controller.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_action_button.dart'; +import 'package:sshnp_gui/src/presentation/widgets/utility/custom_snack_bar.dart'; +import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; + +class ProfileTerminalAction extends ConsumerStatefulWidget { + final SSHNPParams params; + const ProfileTerminalAction(this.params, {Key? key}) : super(key: key); + + @override + ConsumerState createState() => + _ProfileTerminalActionState(); +} + +class _ProfileTerminalActionState extends ConsumerState { + Future onPressed() async { + if (mounted) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) => + const Center(child: CircularProgressIndicator()), + ); + } + + try { + SSHNPParams params = SSHNPParams.merge( + widget.params, + SSHNPPartialParams( + legacyDaemon: false, + sshClient: 'pure-dart', + ), + ); + + final sshnp = await SSHNP.fromParams( + params, + atClient: AtClientManager.getInstance().atClient, + sshrvGenerator: SSHRV.pureDart, + ); + + await sshnp.init(); + final result = await sshnp.run(); + if (result is SSHNPFailed) { + throw result; + } + + /// Issue a new session id + final sessionId = + ref.watch(terminalSessionController.notifier).createSession(); + + /// Create the session controller for the new session id + final sessionController = + ref.watch(terminalSessionFamilyController(sessionId).notifier); + + if (result is SSHNPCommandResult) { + /// Set the command for the new session + sessionController.setProcess( + command: result.command, args: result.args); + sessionController.issueDisplayName(widget.params.profileName!); + ref.read(navigationRailController.notifier).setRoute(AppRoute.terminal); + if (mounted) { + context.pushReplacementNamed(AppRoute.terminal.name); + } + } + } catch (e) { + if (mounted) { + context.pop(); + CustomSnackBar.error(content: e.toString()); + } + } + } + + @override + Widget build(BuildContext context) { + return ProfileActionButton( + onPressed: () async { + await onPressed(); + }, + icon: const Icon(Icons.terminal), + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar.dart new file mode 100644 index 000000000..35bc7085e --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:sshnp_gui/src/controllers/config_controller.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_actions.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; + +import 'package:sshnp_gui/src/presentation/widgets/profile_bar/profile_bar_actions.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_bar/profile_bar_stats.dart'; + +class ProfileBar extends ConsumerStatefulWidget { + final String profileName; + const ProfileBar(this.profileName, {Key? key}) : super(key: key); + + @override + ConsumerState createState() => _ProfileBarState(); +} + +class _ProfileBarState extends ConsumerState { + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + final controller = ref.watch(configFamilyController(widget.profileName)); + return controller.when( + loading: () => const LinearProgressIndicator(), + error: (error, stackTrace) { + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(widget.profileName), + gapW8, + Expanded(child: Container()), + Text(strings.corruptedProfile), + ProfileDeleteAction(widget.profileName), + ], + ), + ); + }, + data: (profile) => Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(widget.profileName), + gapW8, + Expanded(child: Container()), + const ProfileBarStats(), + ProfileBarActions(profile), + ], + ), + ), + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar_actions.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar_actions.dart new file mode 100644 index 000000000..8d149ad34 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar_actions.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:sshnoports/sshnp/sshnp.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_actions/profile_actions.dart'; + +class ProfileBarActions extends StatelessWidget { + final SSHNPParams params; + const ProfileBarActions(this.params, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + ProfileRunAction(params), + ProfileTerminalAction(params), + ProfileMenuButton(params.profileName!), + ], + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar_stats.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar_stats.dart new file mode 100644 index 000000000..714a27388 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_bar/profile_bar_stats.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class ProfileBarStats extends StatelessWidget { + const ProfileBarStats({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const SizedBox( + width: 0, + height: 0, + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/custom_text_form_field.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_form/custom_text_form_field.dart similarity index 80% rename from packages/sshnp_gui/lib/src/presentation/widgets/custom_text_form_field.dart rename to packages/sshnp_gui/lib/src/presentation/widgets/profile_form/custom_text_form_field.dart index 8936b9dc2..407395329 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/custom_text_form_field.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_form/custom_text_form_field.dart @@ -1,15 +1,17 @@ import 'package:flutter/material.dart'; class CustomTextFormField extends StatelessWidget { + static const defaultWidth = 192.0; + static const defaultHeight = 33.0; const CustomTextFormField({ super.key, required this.labelText, this.initialValue, this.validator, - this.onSaved, + this.onChanged, this.hintText, - this.width = 192, - this.height = 33, + this.width = defaultWidth, + this.height = defaultHeight, }); final String labelText; @@ -17,7 +19,7 @@ class CustomTextFormField extends StatelessWidget { final String? initialValue; final double width; final double height; - final void Function(String?)? onSaved; + final void Function(String)? onChanged; final String? Function(String?)? validator; @override @@ -35,7 +37,7 @@ class CustomTextFormField extends StatelessWidget { hintText: hintText, hintStyle: Theme.of(context).textTheme.bodyLarge, ), - onSaved: onSaved, + onChanged: onChanged, validator: validator, ), ); diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/profile_form/profile_form.dart b/packages/sshnp_gui/lib/src/presentation/widgets/profile_form/profile_form.dart new file mode 100644 index 000000000..a022ff733 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/profile_form/profile_form.dart @@ -0,0 +1,306 @@ +import 'package:at_client_mobile/at_client_mobile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sshnoports/sshnp/sshnp.dart'; +import 'package:sshnp_gui/src/controllers/navigation_rail_controller.dart'; +import 'package:sshnp_gui/src/controllers/config_controller.dart'; +import 'package:sshnp_gui/src/presentation/widgets/profile_form/custom_text_form_field.dart'; +import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; +import 'package:sshnp_gui/src/utility/form_validator.dart'; + +class ProfileForm extends ConsumerStatefulWidget { + const ProfileForm({super.key}); + + @override + ConsumerState createState() => _ProfileFormState(); +} + +class _ProfileFormState extends ConsumerState { + final GlobalKey _formkey = GlobalKey(); + late CurrentConfigState currentProfile; + SSHNPPartialParams newConfig = SSHNPPartialParams.empty(); + @override + void initState() { + super.initState(); + } + + void onSubmit(SSHNPParams oldConfig, SSHNPPartialParams newConfig) async { + if (_formkey.currentState!.validate()) { + _formkey.currentState!.save(); + final controller = ref.read(configFamilyController( + newConfig.profileName ?? oldConfig.profileName!) + .notifier); + bool rename = newConfig.profileName.isNotNull && + newConfig.profileName!.isNotEmpty && + oldConfig.profileName.isNotNull && + oldConfig.profileName!.isNotEmpty && + newConfig.profileName != oldConfig.profileName; + SSHNPParams config = SSHNPParams.merge(oldConfig, newConfig); + + if (rename) { + // delete old config file and write the new one + await controller.putConfig(config, + oldProfileName: oldConfig.profileName!, context: context); + } else { + // create new config file + await controller.putConfig(config, context: context); + } + if (mounted) { + ref.read(navigationRailController.notifier).setRoute(AppRoute.home); + context.pushReplacementNamed(AppRoute.home.name); + } + } + } + + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + currentProfile = ref.watch(currentConfigController); + + final asyncOldConfig = + ref.watch(configFamilyController(currentProfile.profileName)); + return asyncOldConfig.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, stack) => Center(child: Text(error.toString())), + data: (oldConfig) { + return SingleChildScrollView( + child: Form( + key: _formkey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomTextFormField( + initialValue: oldConfig.profileName, + labelText: strings.profileName, + onChanged: (value) { + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(profileName: value), + ); + }, + validator: FormValidator.validateProfileNameField, + ), + gapW8, + CustomTextFormField( + initialValue: oldConfig.device, + labelText: strings.device, + onChanged: (value) => + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(device: value), + ), + ), + ], + ), + gapH10, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomTextFormField( + initialValue: oldConfig.sshnpdAtSign ?? '', + labelText: strings.sshnpdAtSign, + onChanged: (value) => + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(sshnpdAtSign: value), + ), + validator: FormValidator.validateAtsignField, + ), + gapW8, + CustomTextFormField( + initialValue: oldConfig.host ?? '', + labelText: strings.host, + onChanged: (value) => + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(host: value), + ), + validator: FormValidator.validateRequiredField, + ), + ], + ), + gapH10, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomTextFormField( + initialValue: oldConfig.sendSshPublicKey, + labelText: strings.sendSshPublicKey, + onChanged: (value) => + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(sendSshPublicKey: value), + ), + ), + gapW8, + SizedBox( + width: CustomTextFormField.defaultWidth, + height: CustomTextFormField.defaultHeight, + child: Row( + children: [ + Text(strings.rsa), + gapW8, + Switch( + value: newConfig.rsa ?? oldConfig.rsa, + onChanged: (newValue) { + setState(() { + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(rsa: newValue), + ); + }); + }, + ), + ], + ), + ), + ], + ), + gapH10, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomTextFormField( + initialValue: oldConfig.remoteUsername ?? '', + labelText: strings.remoteUserName, + onChanged: (value) { + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(remoteUsername: value), + ); + }), + gapW8, + CustomTextFormField( + initialValue: oldConfig.port.toString(), + labelText: strings.port, + onChanged: (value) => + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(port: int.tryParse(value)), + ), + validator: FormValidator.validateRequiredField, + ), + ], + ), + gapH10, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomTextFormField( + initialValue: oldConfig.localPort.toString(), + labelText: strings.localPort, + onChanged: (value) => + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(localPort: int.tryParse(value)), + ), + ), + gapW8, + CustomTextFormField( + initialValue: oldConfig.localSshdPort.toString(), + labelText: strings.localSshdPort, + onChanged: (value) => + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams( + localSshdPort: int.tryParse(value)), + ), + ), + ], + ), + gapH10, + CustomTextFormField( + initialValue: oldConfig.localSshOptions.join(','), + hintText: strings.localSshOptionsHint, + labelText: strings.localSshOptions, + //Double the width of the text field (+8 for the gapW8) + width: CustomTextFormField.defaultWidth * 2 + 8, + onChanged: (value) => newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(localSshOptions: value.split(',')), + ), + ), + gapH10, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomTextFormField( + initialValue: oldConfig.atKeysFilePath, + labelText: strings.atKeysFilePath, + onChanged: (value) => + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(atKeysFilePath: value), + ), + ), + gapW8, + CustomTextFormField( + initialValue: oldConfig.rootDomain, + labelText: strings.rootDomain, + onChanged: (value) => + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(rootDomain: value), + ), + ), + ], + ), + gapH10, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: CustomTextFormField.defaultWidth, + height: CustomTextFormField.defaultHeight, + child: Row( + children: [ + Text(strings.verbose), + gapW8, + Switch( + value: newConfig.verbose ?? oldConfig.verbose, + onChanged: (newValue) { + setState(() { + newConfig = SSHNPPartialParams.merge( + newConfig, + SSHNPPartialParams(verbose: newValue), + ); + }); + }, + ), + ], + ), + ), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ElevatedButton( + onPressed: () => onSubmit(oldConfig, newConfig), + child: Text(strings.submit), + ), + gapW8, + TextButton( + onPressed: () { + ref + .read(navigationRailController.notifier) + .setRoute(AppRoute.home); + context.pushReplacementNamed(AppRoute.home.name); + }, + child: Text(strings.cancel), + ), + ], + ), + ], + ), + ), + ); + }); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/settings_button.dart b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_action_button.dart similarity index 83% rename from packages/sshnp_gui/lib/src/presentation/widgets/settings_button.dart rename to packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_action_button.dart index 4300bc0e9..1bb1b2b2f 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/settings_button.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_action_button.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:sshnp_gui/src/utils/constants.dart'; +import 'package:sshnp_gui/src/utility/constants.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; -import '../../utils/sizes.dart'; - -class SettingsButton extends StatelessWidget { - const SettingsButton({ +class SettingsActionButton extends StatelessWidget { + const SettingsActionButton({ required this.icon, required this.title, required this.onTap, diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_actions.dart b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_actions.dart new file mode 100644 index 000000000..9e15d92b4 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_actions.dart @@ -0,0 +1,6 @@ +export 'settings_backup_keys_action.dart'; +export 'settings_contact_action.dart'; +export 'settings_faq_action.dart'; +export 'settings_switch_atsign_action.dart'; +export 'settings_privacy_policy_action.dart'; +export 'settings_reset_app_action.dart'; diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_backup_keys_action.dart b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_backup_keys_action.dart new file mode 100644 index 000000000..ff8a69b10 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_backup_keys_action.dart @@ -0,0 +1,21 @@ +import 'package:at_contacts_flutter/services/contact_service.dart'; +import 'package:at_onboarding_flutter/at_onboarding_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:sshnp_gui/src/presentation/widgets/settings_actions/settings_action_button.dart'; + +class SettingsBackupKeyAction extends StatelessWidget { + const SettingsBackupKeyAction({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + return SettingsActionButton( + icon: Icons.bookmark_outline, + title: strings.backupYourKeys, + onTap: () { + BackupKeyWidget(atsign: ContactService().currentAtsign).showBackupDialog(context); + }, + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_contact_action.dart b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_contact_action.dart new file mode 100644 index 000000000..0b75b0f92 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_contact_action.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:sshnp_gui/src/presentation/widgets/settings_actions/settings_action_button.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class SettingsContactAction extends StatelessWidget { + const SettingsContactAction({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + return SettingsActionButton( + icon: Icons.forum_outlined, + title: strings.contactUs, + onTap: () async { + Uri emailUri = Uri( + scheme: 'mailto', + path: 'atDataBrowser@atsign.com', + ); + if (!await launchUrl(emailUri)) { + throw Exception('Could not launch $emailUri'); + } + }, + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_faq_action.dart b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_faq_action.dart new file mode 100644 index 000000000..797be28d2 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_faq_action.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:sshnp_gui/src/presentation/widgets/settings_actions/settings_action_button.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SettingsFaqAction extends StatelessWidget { + const SettingsFaqAction({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + return SettingsActionButton( + icon: Icons.help_center_outlined, + title: strings.faq, + onTap: () async { + final Uri url = Uri.parse('https://atsign.com/faqs/'); + if (!await launchUrl(url)) { + throw Exception('Could not launch $url'); + } + }, + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_privacy_policy_action.dart b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_privacy_policy_action.dart new file mode 100644 index 000000000..fac90a2a3 --- /dev/null +++ b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_privacy_policy_action.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:sshnp_gui/src/presentation/widgets/settings_actions/settings_action_button.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SettingsPrivacyPolicyAction extends StatelessWidget { + const SettingsPrivacyPolicyAction({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + return SettingsActionButton( + icon: Icons.account_balance_wallet_outlined, + title: strings.privacyPolicy, + onTap: () async { + final Uri url = Uri.parse('https://atsign.com/apps/atdatabrowser-privacy-policy/'); + if (!await launchUrl(url)) { + throw Exception('Could not launch $url'); + } + }, + ); + } +} diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/reset_app_button.dart b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_reset_app_action.dart similarity index 92% rename from packages/sshnp_gui/lib/src/presentation/widgets/reset_app_button.dart rename to packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_reset_app_action.dart index e072c8760..14dfcf166 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/reset_app_button.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_reset_app_action.dart @@ -1,35 +1,34 @@ import 'package:at_onboarding_flutter/services/sdk_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; - -import '../../../main.dart'; -import '../../utils/at_error_dialog.dart'; -import '../../utils/sizes.dart'; -import 'settings_button.dart'; +import 'package:sshnp_gui/src/presentation/widgets/settings_actions/settings_action_button.dart'; +import 'package:sshnp_gui/src/presentation/widgets/utility/at_error_dialog.dart'; +import 'package:sshnp_gui/src/utility/platform_utility/platform_utililty.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; /// Custom reset button widget is to reset an atsign from keychain list, -class ResetAppButton extends StatefulWidget { +class SettingsResetAppAction extends StatefulWidget { final String? buttonText; final bool isOnboardingScreen; - const ResetAppButton({ + const SettingsResetAppAction({ Key? key, this.buttonText, this.isOnboardingScreen = false, }) : super(key: key); @override - State createState() => _ResetAppButtonState(); + State createState() => _SettingsResetAppActionState(); } -class _ResetAppButtonState extends State { +class _SettingsResetAppActionState extends State { bool? loading = false; @override Widget build(BuildContext context) { if (!widget.isOnboardingScreen) { - return SettingsButton( + return SettingsActionButton( icon: Icons.restart_alt_outlined, title: 'Reset atsign', onTap: _showResetDialog, @@ -213,9 +212,10 @@ class _ResetAppButtonState extends State { List? atsignsList = await SDKService().getAtsignList(); if (atsignsList == null || atsignsList.length < 2) { if (mounted) { + PlatformUtility platformUtility = PlatformUtility.current(); await Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (BuildContext context) => const MyApp(), + builder: (BuildContext context) => platformUtility.app, ), ); } diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/switch_atsign.dart b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_switch_atsign_action.dart similarity index 80% rename from packages/sshnp_gui/lib/src/presentation/widgets/switch_atsign.dart rename to packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_switch_atsign_action.dart index a61eb0237..a21a3b3eb 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/switch_atsign.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/settings_actions/settings_switch_atsign_action.dart @@ -1,23 +1,41 @@ import 'package:at_common_flutter/services/size_config.dart'; import 'package:at_contact/at_contact.dart'; import 'package:at_contacts_flutter/widgets/circular_contacts.dart'; - import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sshnp_gui/src/controllers/authentication_controller.dart'; +import 'package:sshnp_gui/src/presentation/widgets/settings_actions/settings_action_button.dart'; +import 'package:sshnp_gui/src/presentation/widgets/utility/custom_snack_bar.dart'; +import 'package:sshnp_gui/src/repository/authentication_repository.dart'; +import 'package:sshnp_gui/src/repository/navigation_repository.dart'; + +class SettingsSwitchAtsignAction extends StatelessWidget { + const SettingsSwitchAtsignAction({Key? key}) : super(key: key); -import '../../controllers/authentication_controller.dart'; -import '../../repository/authentication_repository.dart'; -import 'snackbars.dart'; + @override + Widget build(BuildContext context) { + final strings = AppLocalizations.of(context)!; + return SettingsActionButton( + icon: Icons.logout_rounded, + title: strings.switchAtsign, + onTap: () async { + await showModalBottomSheet( + context: NavigationRepository.navKey.currentContext!, + builder: (context) => const SwitchAtSignBottomSheet()); + }, + ); + } +} -class AtSignBottomSheet extends ConsumerStatefulWidget { - const AtSignBottomSheet({Key? key}) : super(key: key); +class SwitchAtSignBottomSheet extends ConsumerStatefulWidget { + const SwitchAtSignBottomSheet({Key? key}) : super(key: key); @override - _AtSignBottomSheetState createState() => _AtSignBottomSheetState(); + ConsumerState createState() => _AtSignBottomSheetState(); } -class _AtSignBottomSheetState extends ConsumerState { +class _AtSignBottomSheetState extends ConsumerState { bool isLoading = false; @override @@ -91,7 +109,7 @@ class _AtSignBottomSheetState extends ConsumerState { } else if (!snapshot.hasData) { return const CircularProgressIndicator(); } else { - SnackBars.errorSnackBar(content: strings.error); + CustomSnackBar.error(content: strings.error); return const SizedBox(); } })); diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/sshnp_result_alert_dialog.dart b/packages/sshnp_gui/lib/src/presentation/widgets/sshnp_result_alert_dialog.dart deleted file mode 100644 index 6d8a4e509..000000000 --- a/packages/sshnp_gui/lib/src/presentation/widgets/sshnp_result_alert_dialog.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../controllers/minor_providers.dart'; -import '../../utils/app_router.dart'; - -class SSHNPResultAlertDialog extends ConsumerWidget { - const SSHNPResultAlertDialog({required this.result, required this.title, super.key}); - - final String result; - final String title; - - void copyToClipBoard({ - required BuildContext context, - required String clipboardSuccessText, - }) { - Clipboard.setData(ClipboardData(text: result)).then((value) => ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(clipboardSuccessText), - ), - )); - } - - void ssh({ - required WidgetRef ref, - required BuildContext context, - }) { - ref.read(currentNavIndexProvider.notifier).update((state) => AppRoute.terminal.index - 1); - ref.read(terminalSSHCommandProvider.notifier).update((state) => result); - context.pushReplacementNamed(AppRoute.terminal.name); - } - - @override - Widget build( - BuildContext context, - WidgetRef ref, - ) { - final strings = AppLocalizations.of(context)!; - - return Padding( - padding: const EdgeInsets.only(left: 72), - child: Center( - child: AlertDialog( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded(child: Center(child: Text(title))), - result.contains('ssh') - ? IconButton( - icon: const Icon(Icons.copy_outlined), - onPressed: () => copyToClipBoard( - context: context, - clipboardSuccessText: strings.copiedToClipboard, - ), - ) - : const SizedBox.shrink() - ], - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text.rich( - TextSpan( - children: [ - TextSpan( - text: result, - style: Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w700), - ), - ], - ), - ) - ], - ), - actions: [ - OutlinedButton( - onPressed: () => Navigator.of(context).pop(false), - child: Text(strings.closeButton, - style: Theme.of(context).textTheme.bodyLarge!.copyWith(decoration: TextDecoration.underline)), - ), - result.contains('ssh') - ? OutlinedButton( - onPressed: () => ssh(context: context, ref: ref), - child: Text(strings.sshButton, - style: Theme.of(context).textTheme.bodyLarge!.copyWith(decoration: TextDecoration.underline)), - ) - : const SizedBox.shrink(), - ], - ), - ), - ); - } -} diff --git a/packages/sshnp_gui/lib/src/utils/at_error_dialog.dart b/packages/sshnp_gui/lib/src/presentation/widgets/utility/at_error_dialog.dart similarity index 100% rename from packages/sshnp_gui/lib/src/utils/at_error_dialog.dart rename to packages/sshnp_gui/lib/src/presentation/widgets/utility/at_error_dialog.dart diff --git a/packages/sshnp_gui/lib/src/presentation/widgets/snackbars.dart b/packages/sshnp_gui/lib/src/presentation/widgets/utility/custom_snack_bar.dart similarity index 51% rename from packages/sshnp_gui/lib/src/presentation/widgets/snackbars.dart rename to packages/sshnp_gui/lib/src/presentation/widgets/utility/custom_snack_bar.dart index b7f1791d2..b2d9525db 100644 --- a/packages/sshnp_gui/lib/src/presentation/widgets/snackbars.dart +++ b/packages/sshnp_gui/lib/src/presentation/widgets/utility/custom_snack_bar.dart @@ -1,27 +1,25 @@ import 'package:flutter/material.dart'; +import 'package:sshnp_gui/src/repository/navigation_repository.dart'; -import '../../repository/navigation_service.dart'; - -final _context = NavigationService.navKey.currentContext!; - -class SnackBars extends StatelessWidget { - const SnackBars({Key? key}) : super(key: key); - static void errorSnackBar({ +class CustomSnackBar { + static void error({ required String content, }) { - ScaffoldMessenger.of(_context).showSnackBar(SnackBar( + final context = NavigationRepository.navKey.currentContext!; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text( content, textAlign: TextAlign.center, ), - backgroundColor: Theme.of(_context).colorScheme.error, + backgroundColor: Theme.of(context).colorScheme.error, )); } - static void successSnackBar({ + static void success({ required String content, }) { - ScaffoldMessenger.of(_context).showSnackBar(SnackBar( + final context = NavigationRepository.navKey.currentContext!; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text( content, textAlign: TextAlign.center, @@ -30,12 +28,13 @@ class SnackBars extends StatelessWidget { )); } - static void notificationSnackBar({ + static void notification({ required String content, SnackBarAction? action, Duration duration = const Duration(seconds: 2), }) { - ScaffoldMessenger.of(_context).showSnackBar(SnackBar( + final context = NavigationRepository.navKey.currentContext!; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text( content, textAlign: TextAlign.center, @@ -45,9 +44,4 @@ class SnackBars extends StatelessWidget { // backgroundColor: kDataStorageColor, )); } - - @override - Widget build(BuildContext context) { - return Container(); - } } diff --git a/packages/sshnp_gui/lib/src/repository/authentication_repository.dart b/packages/sshnp_gui/lib/src/repository/authentication_repository.dart index 43cee100b..a58cc896e 100644 --- a/packages/sshnp_gui/lib/src/repository/authentication_repository.dart +++ b/packages/sshnp_gui/lib/src/repository/authentication_repository.dart @@ -1,4 +1,3 @@ -// 🎯 Dart imports: import 'dart:async'; import 'package:at_app_flutter/at_app_flutter.dart'; @@ -12,11 +11,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sshnoports/sshnpd/sshnpd.dart'; +import 'package:sshnp_gui/src/presentation/widgets/utility/custom_snack_bar.dart'; +import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; +import 'package:sshnp_gui/src/repository/navigation_repository.dart'; -import '../presentation/widgets/snackbars.dart'; -// import '../utils/my_sync_progress_listener.dart'; -import '../utils/app_router.dart'; -import 'navigation_service.dart'; +/// A provider that exposes an [AuthenticationRepository] instance to the app. +final authenticationRepositoryProvider = Provider((ref) => AuthenticationRepository()); /// A singleton that makes all the network calls to the @platform. class AuthenticationRepository { @@ -56,20 +57,16 @@ class AuthenticationRepository { return AtClientPreference() ..rootDomain = AtEnv.rootDomain - ..namespace = AtEnv.appNamespace + ..namespace = SSHNPD.namespace ..hiveStoragePath = dir.path ..commitLogPath = dir.path ..isLocalStoreRequired = true; - // TODO - // * By default, this configuration is suitable for most applications - // * In advanced cases you may need to modify [AtClientPreference] - // * Read more here: https://pub.dev/documentation/at_client/latest/at_client/AtClientPreference-class.html } /// Signs user into the @platform. void handleSwitchAtsign(String? atsign) async { final result = await AtOnboarding.onboard( - context: NavigationService.navKey.currentContext!, + context: NavigationRepository.navKey.currentContext!, isSwitchingAtsign: true, atsign: atsign, config: AtOnboardingConfig( @@ -85,7 +82,7 @@ class AuthenticationRepository { // DudeService.getInstance().monitorNotifications(NavigationService.navKey.currentContext!); // AtClientManager.getInstance().atClient.syncService.addProgressListener(MySyncProgressListener()); initializeContactsService(rootDomain: AtEnv.rootDomain); - final context = NavigationService.navKey.currentContext!; + final context = NavigationRepository.navKey.currentContext!; if (context.mounted) { context.goNamed(AppRoute.home.name); } @@ -94,7 +91,7 @@ class AuthenticationRepository { case AtOnboardingResultStatus.error: _logger.severe('Onboarding throws ${result.message} error'); - SnackBars.errorSnackBar(content: result.message ?? ''); + CustomSnackBar.error(content: result.message ?? ''); break; case AtOnboardingResultStatus.cancel: @@ -153,8 +150,3 @@ class AuthenticationRepository { return await getAtSignDetails(atSign!); } } - -/// A provider that exposes an [AuthenticationRepository] instance to the app. -final authenticationRepositoryProvider = Provider((ref) { - return AuthenticationRepository(); -}); diff --git a/packages/sshnp_gui/lib/src/repository/navigation_service.dart b/packages/sshnp_gui/lib/src/repository/navigation_repository.dart similarity index 52% rename from packages/sshnp_gui/lib/src/repository/navigation_service.dart rename to packages/sshnp_gui/lib/src/repository/navigation_repository.dart index 5dcea19cb..06b073c04 100644 --- a/packages/sshnp_gui/lib/src/repository/navigation_service.dart +++ b/packages/sshnp_gui/lib/src/repository/navigation_repository.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -class NavigationService { +class NavigationRepository { static GlobalKey navKey = GlobalKey(); - - static GlobalKey nesteNavKey = GlobalKey(); } diff --git a/packages/sshnp_gui/lib/src/utils/theme.dart b/packages/sshnp_gui/lib/src/utility/app_theme.dart similarity index 98% rename from packages/sshnp_gui/lib/src/utils/theme.dart rename to packages/sshnp_gui/lib/src/utility/app_theme.dart index 7fcd6988e..e100b1828 100644 --- a/packages/sshnp_gui/lib/src/utils/theme.dart +++ b/packages/sshnp_gui/lib/src/utility/app_theme.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:macos_ui/macos_ui.dart'; -import 'package:sshnp_gui/src/utils/sizes.dart'; - -import 'constants.dart'; +import 'package:sshnp_gui/src/utility/constants.dart'; +import 'package:sshnp_gui/src/utility/sizes.dart'; class AppTheme { + static TextTheme lightTextTheme = const TextTheme( // displayLarge: TextStyle( // fontSize: 80, diff --git a/packages/sshnp_gui/lib/src/utility/constants.dart b/packages/sshnp_gui/lib/src/utility/constants.dart new file mode 100644 index 000000000..dd2457eef --- /dev/null +++ b/packages/sshnp_gui/lib/src/utility/constants.dart @@ -0,0 +1,19 @@ +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart'; + +const kPrimaryColor = Color(0xFFF05E3E); +// const kBackGroundColorDark = Color(0xFF242424); +const kBackGroundColorDark = Color(0xFF222222); + +const kEmptyFieldValidationError = 'Field cannot be left blank'; +const kAtsignFieldValidationError = 'Field must start with @'; +const kProfileNameFieldValidationError = + 'Field must only use alphanumeric characters and spaces'; + +const String dotEnvMimeType = 'text/plain'; +const XTypeGroup dotEnvTypeGroup = XTypeGroup( + label: 'dotenv', + extensions: ['env'], + mimeTypes: [dotEnvMimeType], + uniformTypeIdentifiers: ['com.atsign.sshnp-config'], +); diff --git a/packages/sshnp_gui/lib/src/utility/form_validator.dart b/packages/sshnp_gui/lib/src/utility/form_validator.dart new file mode 100644 index 000000000..2e654333d --- /dev/null +++ b/packages/sshnp_gui/lib/src/utility/form_validator.dart @@ -0,0 +1,29 @@ +import 'package:sshnp_gui/src/utility/constants.dart'; + +class FormValidator { + static String? validateRequiredField(String? value) { + if (value?.isEmpty ?? true) { + return kEmptyFieldValidationError; + } + return null; + } + + static String? validateAtsignField(String? value) { + if (value?.isEmpty ?? true) { + return kEmptyFieldValidationError; + } else if (!value!.startsWith('@')) { + return kAtsignFieldValidationError; + } + return null; + } + + static String? validateProfileNameField(String? value) { + String invalid = '[^a-zA-Z0-9 ]'; + if (value?.isEmpty ?? true) { + return kEmptyFieldValidationError; + } else if (value!.contains(RegExp(invalid))) { + return kProfileNameFieldValidationError; + } + return null; + } +} diff --git a/packages/sshnp_gui/lib/src/utility/platform_utility/default_platform_utility.dart b/packages/sshnp_gui/lib/src/utility/platform_utility/default_platform_utility.dart new file mode 100644 index 000000000..ec8f5fab3 --- /dev/null +++ b/packages/sshnp_gui/lib/src/utility/platform_utility/default_platform_utility.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; +import 'package:sshnp_gui/src/utility/app_theme.dart'; +import 'package:sshnp_gui/src/utility/platform_utility/platform_utililty.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class DefaultPlatformUtility implements PlatformUtility { + const DefaultPlatformUtility(); + + @override + void configurePlatform() {} + + @override + bool isPlatform() => true; + + @override + Widget get app => const _MyApp(); +} + +class _MyApp extends ConsumerWidget { + const _MyApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MaterialApp.router( + title: 'SSHNP', + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: ref.watch(navigationController), + theme: AppTheme.dark(), + // * The onboarding screen (first screen)p[] + ); + } +} diff --git a/packages/sshnp_gui/lib/src/utility/platform_utility/macos_utility.dart b/packages/sshnp_gui/lib/src/utility/platform_utility/macos_utility.dart new file mode 100644 index 000000000..3429dab01 --- /dev/null +++ b/packages/sshnp_gui/lib/src/utility/platform_utility/macos_utility.dart @@ -0,0 +1,49 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:macos_ui/macos_ui.dart'; +import 'package:sshnp_gui/src/controllers/navigation_controller.dart'; +import 'package:sshnp_gui/src/utility/app_theme.dart'; +import 'package:sshnp_gui/src/utility/platform_utility/default_platform_utility.dart'; +import 'package:sshnp_gui/src/utility/platform_utility/platform_utililty.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class MacosUtility implements PlatformUtility { + const MacosUtility(); + + @override + Future configurePlatform() async { + return; + const config = MacosWindowUtilsConfig(toolbarStyle: NSWindowToolbarStyle.unified); + await config.apply(); + } + + @override + bool isPlatform() { + return Platform.isMacOS && !kIsWeb; + } + + @override + Widget get app => const DefaultPlatformUtility().app; //const _MyApp(); +} + +class _MyApp extends ConsumerWidget { + const _MyApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return MacosApp.router( + title: 'SSHNP', + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + routerConfig: ref.watch(navigationController), + theme: AppTheme.macosDark(), + darkTheme: AppTheme.macosDark(), + themeMode: ThemeMode.dark, + // * The onboarding screen (first screen)p[] + ); + } +} \ No newline at end of file diff --git a/packages/sshnp_gui/lib/src/utility/platform_utility/platform_utililty.dart b/packages/sshnp_gui/lib/src/utility/platform_utility/platform_utililty.dart new file mode 100644 index 000000000..bb8d2462a --- /dev/null +++ b/packages/sshnp_gui/lib/src/utility/platform_utility/platform_utililty.dart @@ -0,0 +1,24 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:sshnp_gui/src/utility/platform_utility/default_platform_utility.dart'; +import 'package:sshnp_gui/src/utility/platform_utility/macos_utility.dart'; + +abstract class PlatformUtility { + bool isPlatform(); + FutureOr configurePlatform(); + Widget get app; + + static const _platforms = [ + MacosUtility(), + ]; + + factory PlatformUtility.current() { + for (var platform in _platforms) { + if (platform.isPlatform()) { + return platform; + } + } + return const DefaultPlatformUtility(); + } +} diff --git a/packages/sshnp_gui/lib/src/utils/sizes.dart b/packages/sshnp_gui/lib/src/utility/sizes.dart similarity index 92% rename from packages/sshnp_gui/lib/src/utils/sizes.dart rename to packages/sshnp_gui/lib/src/utility/sizes.dart index 5569f28fb..4f7acd158 100644 --- a/packages/sshnp_gui/lib/src/utils/sizes.dart +++ b/packages/sshnp_gui/lib/src/utility/sizes.dart @@ -5,7 +5,7 @@ class Sizes { static const p2 = 2.0; static const p3 = 3.0; static const p4 = 4.0; - // static const p8 = 8.0; + static const p8 = 8.0; static const p10 = 10.0; static const p12 = 12.0; // static const p14 = 14.0; @@ -26,7 +26,7 @@ class Sizes { const gap0 = SizedBox(); /// Constant gap widths -// const gapW8 = SizedBox(width: Sizes.p8); +const gapW8 = SizedBox(width: Sizes.p8); const gapW12 = SizedBox(width: Sizes.p12); const gapW16 = SizedBox(width: Sizes.p16); // const gapW20 = SizedBox(width: Sizes.p20); @@ -37,7 +37,7 @@ const gapW16 = SizedBox(width: Sizes.p16); // /// Constant gap heights const gapH4 = SizedBox(height: Sizes.p4); -// const gapH8 = SizedBox(height: Sizes.p8); +const gapH8 = SizedBox(height: Sizes.p8); const gapH10 = SizedBox(height: Sizes.p10); const gapH12 = SizedBox(height: Sizes.p12); const gapH16 = SizedBox(height: Sizes.p16); diff --git a/packages/sshnp_gui/lib/src/utils/app_router.dart b/packages/sshnp_gui/lib/src/utils/app_router.dart deleted file mode 100644 index e3668f3ec..000000000 --- a/packages/sshnp_gui/lib/src/utils/app_router.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:sshnp_gui/src/presentation/screens/new_connection_screen.dart'; -import 'package:sshnp_gui/src/repository/navigation_service.dart'; - -import '../presentation/screens/home_screen.dart'; -import '../presentation/screens/onboarding_screen.dart'; -import '../presentation/screens/settings_screen.dart'; -import '../presentation/screens/terminal_screen.dart'; - -enum AppRoute { - onboarding, - home, - newConnection, - terminal, - settings, -} - -final goRouterProvider = Provider((ref) => GoRouter( - navigatorKey: NavigationService.navKey, - initialLocation: '/', - debugLogDiagnostics: false, - routes: [ - GoRoute( - path: '/', - builder: (context, state) => const OnboardingScreen(), - name: AppRoute.onboarding.name, - routes: [ - GoRoute( - path: 'home', - name: AppRoute.home.name, - pageBuilder: (context, state) => CustomTransitionPage( - key: state.pageKey, - child: const HomeScreen(), - transitionsBuilder: ((context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child))), - ), - GoRoute( - path: 'new', - name: AppRoute.newConnection.name, - pageBuilder: (context, state) => CustomTransitionPage( - key: state.pageKey, - child: const NewConnectionScreen(), - transitionsBuilder: ((context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child))), - ), - GoRoute( - path: 'terminal', - name: AppRoute.terminal.name, - pageBuilder: (context, state) => CustomTransitionPage( - key: state.pageKey, - child: const TerminalScreen(), - transitionsBuilder: ((context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child))), - ), - GoRoute( - path: 'settings', - name: AppRoute.settings.name, - pageBuilder: (context, state) => CustomTransitionPage( - key: state.pageKey, - child: const SettingsScreen(), - transitionsBuilder: ((context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child))), - ), - ]), - ], - )); diff --git a/packages/sshnp_gui/lib/src/utils/constants.dart b/packages/sshnp_gui/lib/src/utils/constants.dart deleted file mode 100644 index 353ebc8cd..000000000 --- a/packages/sshnp_gui/lib/src/utils/constants.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter/material.dart'; - -const kPrimaryColor = Color(0xFFF05E3E); -// const kBackGroundColorDark = Color(0xFF242424); -const kBackGroundColorDark = Color(0xFF222222); - -const kEmptyFieldValidationError = 'Field Cannot be left blank'; -const kAtsignFieldValidationError = 'Field must start with @'; diff --git a/packages/sshnp_gui/lib/src/utils/enum.dart b/packages/sshnp_gui/lib/src/utils/enum.dart deleted file mode 100644 index 1a0598820..000000000 --- a/packages/sshnp_gui/lib/src/utils/enum.dart +++ /dev/null @@ -1 +0,0 @@ -enum ConfigFileWriteState { create, update } diff --git a/packages/sshnp_gui/lib/src/utils/util.dart b/packages/sshnp_gui/lib/src/utils/util.dart deleted file mode 100644 index 45a25c4a4..000000000 --- a/packages/sshnp_gui/lib/src/utils/util.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; - -class Util { - static bool isMacos() { - if (Platform.isMacOS && !kIsWeb) { - return true; - } else { - return false; - } - } -} diff --git a/packages/sshnp_gui/lib/src/utils/validator.dart b/packages/sshnp_gui/lib/src/utils/validator.dart deleted file mode 100644 index fbd00fa7a..000000000 --- a/packages/sshnp_gui/lib/src/utils/validator.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'constants.dart'; - -class Validator { - static String? validateRequiredField(String? value) { - if (value!.isEmpty) { - return kEmptyFieldValidationError; - } else { - return null; - } - } - - static String? validateAtsignField(String? value) { - if (value!.isEmpty) { - return kEmptyFieldValidationError; - } else if (!value.startsWith('@')) { - return kAtsignFieldValidationError; - } else { - return null; - } - } -} diff --git a/packages/sshnp_gui/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/sshnp_gui/macos/Flutter/GeneratedPluginRegistrant.swift index 92e8470dd..4369eba15 100644 --- a/packages/sshnp_gui/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/sshnp_gui/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,7 +13,7 @@ import macos_ui import macos_window_utils import package_info_plus import path_provider_foundation -import share_plus_macos +import share_plus import shared_preferences_foundation import url_launcher_macos diff --git a/packages/sshnp_gui/macos/Podfile.lock b/packages/sshnp_gui/macos/Podfile.lock index 94f2b8a78..2d6c4cf07 100644 --- a/packages/sshnp_gui/macos/Podfile.lock +++ b/packages/sshnp_gui/macos/Podfile.lock @@ -19,7 +19,7 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - share_plus_macos (0.0.1): + - share_plus (0.0.1): - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter @@ -38,7 +38,7 @@ DEPENDENCIES: - macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - - share_plus_macos (from `Flutter/ephemeral/.symlinks/plugins/share_plus_macos/macos`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -63,8 +63,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin - share_plus_macos: - :path: Flutter/ephemeral/.symlinks/plugins/share_plus_macos/macos + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin url_launcher_macos: @@ -74,17 +74,17 @@ SPEC CHECKSUMS: at_file_saver: 1fc6ed722f17c7a20ce79cce168d1100fcad4b95 biometric_storage: 43caa6e7ef00e8e19c074216e7e1786dacda9e76 device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f - file_selector_macos: f1b08a781e66103e3ba279fd5d4024a2478b3af6 + file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 flutter_pty: 41b6f848ade294be726a6b94cdd4a67c3bc52f59 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 macos_ui: 6229a8922cd97bafb7d9636c8eb8dfb0744183ca macos_window_utils: 933f91f64805e2eb91a5bd057cf97cd097276663 package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 - share_plus_macos: 853ee48e7dce06b633998ca0735d482dd671ade4 + share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7 shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 -COCOAPODS: 1.12.1 +COCOAPODS: 1.11.3 diff --git a/packages/sshnp_gui/macos/Runner.xcodeproj/project.pbxproj b/packages/sshnp_gui/macos/Runner.xcodeproj/project.pbxproj index e4ca216eb..ed6209e3e 100644 --- a/packages/sshnp_gui/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/sshnp_gui/macos/Runner.xcodeproj/project.pbxproj @@ -258,7 +258,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 331C80D4294CF70F00263BE5 = { diff --git a/packages/sshnp_gui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/sshnp_gui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index ed36e2c19..93c98a106 100644 --- a/packages/sshnp_gui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/sshnp_gui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ - + - + @@ -13,7 +13,7 @@ - + @@ -330,14 +330,16 @@ - + - + + + diff --git a/packages/sshnp_gui/macos/Runner/Info.plist b/packages/sshnp_gui/macos/Runner/Info.plist index 4789daa6a..8e0ae8e19 100644 --- a/packages/sshnp_gui/macos/Runner/Info.plist +++ b/packages/sshnp_gui/macos/Runner/Info.plist @@ -28,5 +28,46 @@ MainMenu NSPrincipalClass NSApplication + UTImportedTypeDeclarations + + + UTTypeIdentifier + com.atsign.atkeys + UTTypeConformsTo + + public.json + + UTTypeDescription + Atsign Cryptographic Key File + UTTypeTagSpecification + + public.filename-extension + + atkeys + + + UTTypeReferenceURL + https://github.com/atsign-foundation/at_protocol + + + UTTypeIdentifier + com.atsign.sshnp-config + UTTypeConformsTo + + public.plain-text + + UTTypeDescription + Dotenv File + UTTypeTagSpecification + + public.filename-extension + + env + + + UTTypeReferenceURL + https://12factor.net/ + + diff --git a/packages/sshnp_gui/pubspec.lock b/packages/sshnp_gui/pubspec.lock index 2fe80b908..dc453797c 100644 --- a/packages/sshnp_gui/pubspec.lock +++ b/packages/sshnp_gui/pubspec.lock @@ -58,13 +58,13 @@ packages: source: hosted version: "5.2.0" at_backupkey_flutter: - dependency: transitive + dependency: "direct main" description: name: at_backupkey_flutter - sha256: "012fa7e497052477348d68aa03b0ffe298d3a91b113a5f1f864bcff0a1d54488" + sha256: "5e0eb67988f99f435076db43e2fdd96bb27647764243e1f74fe51dff529634c9" url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "4.0.10" at_base2e15: dependency: transitive description: @@ -93,12 +93,12 @@ packages: dependency: "direct main" description: name: at_client_mobile - sha256: "4e3cfdd22c0edac09540e080ddc8b7cb01c0302bf1fa316656d5126815c2ad3c" + sha256: c9614ad3704c55e637d18352427fc13478ef39a7ae827edf45665c23b9d57c7e url: "https://pub.dev" source: hosted - version: "3.2.10" + version: "3.2.12" at_common_flutter: - dependency: transitive + dependency: "direct main" description: name: at_common_flutter sha256: "7e2c9f9cee67651d61b7009d9c14148858ce8e251ed8239cf75e9fbc6cccd45e" @@ -125,10 +125,10 @@ packages: dependency: "direct main" description: name: at_contacts_flutter - sha256: d274b93d25322b31188ec08def87360cb3c62d8d971ed1d3b68d2f9501b8141d + sha256: "966cc7094ffdf4973c21249d107e3d97e5f5b612dc2f1c6633e9618c9ab69351" url: "https://pub.dev" source: hosted - version: "4.0.10" + version: "4.0.11" at_file_saver: dependency: transitive description: @@ -157,10 +157,10 @@ packages: dependency: "direct main" description: name: at_onboarding_flutter - sha256: aab27143f31bb13ec6993be0c3ed5610f025e5e15df14f2f9ecf9b9e6c97e630 + sha256: f1da8c11c915117e96f145c63756e87aeaa78556b0990bc40756460d360ca18b url: "https://pub.dev" source: hosted - version: "6.1.0" + version: "6.1.3" at_persistence_secondary_server: dependency: transitive description: @@ -213,10 +213,10 @@ packages: dependency: "direct main" description: name: biometric_storage - sha256: f6d7f5f4c28323797658423e4c5982c9dee42e18f59a8a8d4bc5df38eaf2e2f1 + sha256: "2bae7ce64d4e3a390f8adfd0373ed1a82d567e3692e16a1bd0f72f91fb962ae3" url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "5.0.0+4" boolean_selector: dependency: transitive description: @@ -261,10 +261,10 @@ packages: dependency: transitive description: name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 url: "https://pub.dev" source: hosted - version: "1.17.1" + version: "1.17.2" convert: dependency: transitive description: @@ -333,10 +333,10 @@ packages: dependency: transitive description: name: device_info_plus - sha256: f52ab3b76b36ede4d135aab80194df8925b553686f0fa12226b4e2d658e45903 + sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659" url: "https://pub.dev" source: hosted - version: "8.2.2" + version: "9.0.3" device_info_plus_platform_interface: dependency: transitive description: @@ -405,34 +405,50 @@ packages: dependency: transitive description: name: file_picker - sha256: b85eb92b175767fdaa0c543bf3b0d1f610fe966412ea72845fe5ba7801e763ff + sha256: "21145c9c268d54b1f771d8380c195d2d6f655e0567dc1ca2f9c134c02c819e0a" url: "https://pub.dev" source: hosted - version: "5.2.10" + version: "5.3.3" file_selector: - dependency: transitive + dependency: "direct main" description: name: file_selector - sha256: "9e34368bfacdf644e2c8a59e2b241cfb722bcbbd09876410e8775ae4905d6a49" + sha256: "1d2fde93dddf634a9c3c0faa748169d7ac0d83757135555707e52f02c017ad4f" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: d41e165d6f798ca941d536e5dc93494d50e78c571c28ad60cfe0b0fefeb9f1e7 + url: "https://pub.dev" + source: hosted + version: "0.5.0+3" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: b3fbdda64aa2e335df6e111f6b0f1bb968402ed81d2dd1fa4274267999aa32c2 url: "https://pub.dev" source: hosted - version: "0.8.4+3" + version: "0.5.1+6" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: c06249f2082e88aca55f4aad9e4c70ff0f2b61d753c1577d51adeab88b3f0178 + sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492" url: "https://pub.dev" source: hosted - version: "0.0.3" + version: "0.9.2+1" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: e87311d719039da30d26ae829aab3ae66f82deb3318cd70ffecb608c99e3da68 + sha256: "182c3f8350cee659f7b115e956047ee3dc672a96665883a545e81581b9a82c72" url: "https://pub.dev" source: hosted - version: "0.8.2+2" + version: "0.9.3+2" file_selector_platform_interface: dependency: transitive description: @@ -445,18 +461,18 @@ packages: dependency: transitive description: name: file_selector_web - sha256: bf166d08f4c3f79286774cdfa39ed301e076c5a903c435f5199818288f24a66d + sha256: dc6622c4d66cb1bee623ddcc029036603c6cc45c85e4a775bb06008d61c809c1 url: "https://pub.dev" source: hosted - version: "0.8.1+5" + version: "0.9.2+1" file_selector_windows: dependency: transitive description: name: file_selector_windows - sha256: "8bbcc82fe0d3cdf5ae5c289492ddfd703ec028028d9f194dbceae04cfbde1c48" + sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0 url: "https://pub.dev" source: hosted - version: "0.8.2+2" + version: "0.9.3+1" fixnum: dependency: transitive description: @@ -527,10 +543,10 @@ packages: dependency: transitive description: name: flutter_slidable - sha256: c7607eb808cdef19c8468246e95a133308aeaeb3971cdd9edfb9d5e31cedfbe9 + sha256: cc4231579e3eae41ae166660df717f4bad1359c87f4a4322ad8ba1befeb3d2be url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "3.0.0" flutter_svg: dependency: "direct main" description: @@ -606,13 +622,13 @@ packages: source: hosted version: "4.0.2" image: - dependency: "direct overridden" + dependency: transitive description: name: image - sha256: "8e9d133755c3e84c73288363e6343157c383a0c6c56fc51afcc5d4d7180306d6" + sha256: a72242c9a0ffb65d03de1b7113bc4e189686fc07c7147b8b41811d0dd0e0d9bf url: "https://pub.dev" source: hosted - version: "3.3.0" + version: "4.0.17" internet_connection_checker: dependency: transitive description: @@ -689,18 +705,18 @@ packages: dependency: transitive description: name: matcher - sha256: "6501fbd55da300384b768785b83e5ce66991266cec21af89ab9ae7f5ce1c4cbb" + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" url: "https://pub.dev" source: hosted - version: "0.12.15" + version: "0.12.16" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.5.0" meta: dependency: transitive description: @@ -761,10 +777,10 @@ packages: dependency: transitive description: name: package_info_plus - sha256: cbff87676c352d97116af6dbea05aa28c4d65eb0f6d5677a520c11a69ca9a24d + sha256: "6ff267fcd9d48cb61c8df74a82680e8b82e940231bb5f68356672fde0397334a" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "4.1.0" package_info_plus_platform_interface: dependency: transitive description: @@ -849,18 +865,18 @@ packages: dependency: transitive description: name: permission_handler - sha256: "5749ebeb7ec0c3865ea17e3eb337174b87747be816dab582c551e1aff6f6bbf3" + sha256: bc56bfe9d3f44c3c612d8d393bd9b174eb796d706759f9b495ac254e4294baa5 url: "https://pub.dev" source: hosted - version: "9.2.0" + version: "10.4.5" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: a512e0fa8abcb0659d938ec2df93a70eb1df1fdea5fdc6d79a866bfd858a28fc + sha256: "59c6322171c29df93a22d150ad95f3aa19ed86542eaec409ab2691b8f35f9a47" url: "https://pub.dev" source: hosted - version: "9.0.2+1" + version: "10.3.6" permission_handler_apple: dependency: transitive description: @@ -897,10 +913,10 @@ packages: dependency: transitive description: name: pin_code_fields - sha256: c8652519d14688f3fe2a8288d86910a46aa0b9046d728f292d3bf6067c31b4c7 + sha256: "4c0db7fbc889e622e7c71ea54b9ee624bb70c7365b532abea0271b17ea75b729" url: "https://pub.dev" source: hosted - version: "7.4.0" + version: "8.0.1" pinenacl: dependency: transitive description: @@ -993,26 +1009,10 @@ packages: dependency: transitive description: name: share_plus - sha256: f582d5741930f3ad1bf0211d358eddc0508cc346e5b4b248bd1e569c995ebb7a - url: "https://pub.dev" - source: hosted - version: "4.5.3" - share_plus_linux: - dependency: transitive - description: - name: share_plus_linux - sha256: dc32bf9f1151b9864bb86a997c61a487967a08f2e0b4feaa9a10538712224da4 - url: "https://pub.dev" - source: hosted - version: "3.0.1" - share_plus_macos: - dependency: transitive - description: - name: share_plus_macos - sha256: "44daa946f2845045ecd7abb3569b61cd9a55ae9cc4cbec9895b2067b270697ae" + sha256: "6cec740fa0943a826951223e76218df002804adb588235a8910dc3d6b0654e11" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "7.1.0" share_plus_platform_interface: dependency: transitive description: @@ -1021,22 +1021,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.0" - share_plus_web: - dependency: transitive - description: - name: share_plus_web - sha256: eaef05fa8548b372253e772837dd1fbe4ce3aca30ea330765c945d7d4f7c9935 - url: "https://pub.dev" - source: hosted - version: "3.1.0" - share_plus_windows: - dependency: transitive - description: - name: share_plus_windows - sha256: "3a21515ae7d46988d42130cd53294849e280a5de6ace24bae6912a1bffd757d4" - url: "https://pub.dev" - source: hosted - version: "3.0.1" shared_preferences: dependency: "direct main" description: @@ -1129,23 +1113,24 @@ packages: dependency: transitive description: name: showcaseview - sha256: "09b534d806572135c38e06901de4b36b2bbd61739ec56c5fa9242d10748e19df" + sha256: dc62ce38820dead4a27ce39d9e6c98384be89c2f2b4da3255238a59b041c7ccd url: "https://pub.dev" source: hosted - version: "1.1.8" + version: "2.0.3" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.99" socket_connector: - dependency: transitive + dependency: "direct main" description: - name: socket_connector - sha256: f461da716d74eb6fda80efea98244ef467363d69eb6bb307c43722389e70d7b1 - url: "https://pub.dev" - source: hosted - version: "1.0.10" + path: "." + ref: main + resolved-ref: "2efb79d3c223a62e4886690afb8fa82b3cb8a662" + url: "https://github.com/xavierchanth/socket_connector/" + source: git + version: "1.0.11" source_map_stack_trace: dependency: transitive description: @@ -1166,10 +1151,10 @@ packages: dependency: transitive description: name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" ssh_key: dependency: transitive description: @@ -1184,7 +1169,7 @@ packages: path: "../sshnoports" relative: true source: path - version: "4.0.0-rc.3" + version: "4.0.0-rc.4" stack_trace: dependency: transitive description: @@ -1229,26 +1214,26 @@ packages: dependency: transitive description: name: test - sha256: "3dac9aecf2c3991d09b9cdde4f98ded7b30804a88a0d7e4e7e1678e78d6b97f4" + sha256: "13b41f318e2a5751c3169137103b60c584297353d4b1761b66029bae6411fe46" url: "https://pub.dev" source: hosted - version: "1.24.1" + version: "1.24.3" test_api: dependency: transitive description: name: test_api - sha256: eb6ac1540b26de412b3403a163d919ba86f6a973fe6cc50ae3541b80092fdcfb + sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.6.0" test_core: dependency: transitive description: name: test_core - sha256: "5138dbffb77b2289ecb12b81c11ba46036590b72a64a7a90d6ffb880f1a29e93" + sha256: "99806e9e6d95c7b059b7a0fc08f07fc53fabe54a829497f0d9676299f1e8637e" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "0.5.3" tuple: dependency: transitive description: @@ -1274,7 +1259,7 @@ packages: source: hosted version: "1.3.2" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher sha256: "47e208a6711459d813ba18af120d9663c20bdf6985d6ad39fe165d2538378d27" @@ -1325,10 +1310,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: ba140138558fcc3eead51a1c42e92a9fb074a1b1149ed3c73e66035b2ccd94f2 + sha256: "2942294a500b4fa0b918685aff406773ba0a4cd34b7f42198742a94083020ce5" url: "https://pub.dev" source: hosted - version: "2.0.19" + version: "2.0.20" url_launcher_windows: dependency: transitive description: @@ -1338,7 +1323,7 @@ packages: source: hosted version: "3.0.8" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313" @@ -1401,6 +1386,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + url: "https://pub.dev" + source: hosted + version: "0.1.4-beta" web_socket_channel: dependency: transitive description: @@ -1453,10 +1446,18 @@ packages: dependency: transitive description: name: win32 - sha256: a6f0236dbda0f63aa9a25ad1ff9a9d8a4eaaa5012da0dc59d21afdb1dc361ca4 + sha256: "9e82a402b7f3d518fb9c02d0e9ae45952df31b9bf34d77baf19da2de03fc2aaa" + url: "https://pub.dev" + source: hosted + version: "5.0.7" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: e4506d60b7244251bc59df15656a3093501c37fb5af02105a944d73eb95be4c9 url: "https://pub.dev" source: hosted - version: "3.1.4" + version: "1.1.1" xdg_directories: dependency: transitive description: @@ -1498,5 +1499,5 @@ packages: source: hosted version: "0.2.0" sdks: - dart: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + dart: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" diff --git a/packages/sshnp_gui/pubspec.yaml b/packages/sshnp_gui/pubspec.yaml index 6a5fbdfc3..bdf0e63e8 100644 --- a/packages/sshnp_gui/pubspec.yaml +++ b/packages/sshnp_gui/pubspec.yaml @@ -8,12 +8,15 @@ environment: dependencies: at_app_flutter: ^5.0.1 - at_client_mobile: ^3.2.6 + at_backupkey_flutter: ^4.0.10 + at_client_mobile: ^3.2.11 + at_common_flutter: ^2.0.12 at_contact: ^3.0.7 at_contacts_flutter: ^4.0.5 - at_onboarding_flutter: ^6.1.0 + at_onboarding_flutter: ^6.1.3 at_utils: ^3.0.11 - biometric_storage: ^4.1.3 + biometric_storage: ^5.0.0+4 + file_selector: ^0.9.5 flutter: sdk: flutter flutter_dotenv: ^5.0.2 @@ -29,9 +32,11 @@ dependencies: path: ^1.8.3 path_provider: ^2.0.11 shared_preferences: ^2.2.0 + socket_connector: ^1.0.10 sshnoports: path: ../sshnoports/ - + url_launcher: ^6.1.14 + uuid: ^3.0.7 xterm: ^3.5.0 dev_dependencies: @@ -41,8 +46,12 @@ dev_dependencies: dependency_overrides: intl: ^0.17.0-nullsafety.2 - image: ^3.1.3 + # image: ^3.1.3 zxing2: ^0.2.0 + socket_connector: + git: + url: https://github.com/xavierchanth/socket_connector/ + ref: main flutter: uses-material-design: true generate: true diff --git a/packages/sshnp_gui/test/widget_test.dart b/packages/sshnp_gui/test/widget_test.dart index 6a216681b..c64221d56 100644 --- a/packages/sshnp_gui/test/widget_test.dart +++ b/packages/sshnp_gui/test/widget_test.dart @@ -8,12 +8,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sshnp_gui/main.dart'; +import 'package:sshnp_gui/src/utility/platform_utility/default_platform_utility.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + await tester.pumpWidget(const DefaultPlatformUtility().app); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget); diff --git a/packages/sshnp_gui/windows/flutter/generated_plugin_registrant.cc b/packages/sshnp_gui/windows/flutter/generated_plugin_registrant.cc index 762731d61..036f08c22 100644 --- a/packages/sshnp_gui/windows/flutter/generated_plugin_registrant.cc +++ b/packages/sshnp_gui/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/packages/sshnp_gui/windows/flutter/generated_plugins.cmake b/packages/sshnp_gui/windows/flutter/generated_plugins.cmake index 37ff62e99..b91db13cc 100644 --- a/packages/sshnp_gui/windows/flutter/generated_plugins.cmake +++ b/packages/sshnp_gui/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST at_file_saver file_selector_windows permission_handler_windows + share_plus url_launcher_windows )