From 19d50f294712c36ec32dcc8527458a9445f927cc Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 26 May 2025 16:40:18 +0100 Subject: [PATCH] feat: 1-tap connection --- android/app/src/main/AndroidManifest.xml | 6 + ios/Runner/Info.plist | 15 +- lib/app.dart | 7 + lib/const.dart | 1 + lib/i18n/strings.g.dart | 4 +- lib/i18n/strings_en.g.dart | 10 +- lib/i18n/translated/en.i18n.yaml | 5 +- lib/pages/settings_wallet.dart | 132 +++++++++++++++--- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 ++++++++ pubspec.yaml | 1 + .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 13 files changed, 220 insertions(+), 23 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b6707dc..587f5a0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -31,6 +31,12 @@ + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 48ade1a..d092642 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Zap Stream Flutter + zap.stream CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -76,5 +76,18 @@ vi zh + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleURLSchemes + + zswc + + + diff --git a/lib/app.dart b/lib/app.dart index b9a9a4d..8bd6d94 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -29,6 +29,13 @@ void runZapStream() { TextTheme(), ), routerConfig: GoRouter( + redirect: (context, state) { + // redirect back to the wallet settings page + if (state.uri.scheme == "zswc") { + return "/settings/wallet"; + } + return null; + }, routes: [ ShellRoute( observers: [routeObserver], diff --git a/lib/const.dart b/lib/const.dart index 28f9f03..2fca126 100644 --- a/lib/const.dart +++ b/lib/const.dart @@ -35,6 +35,7 @@ const defaultRelays = [ "wss://relay.fountain.fm", ]; const searchRelays = ["wss://relay.nostr.band", "wss://search.nos.today"]; +const nwcRelays = ["wss://relay.getalby.com/v1"]; final loginData = LoginData(); final RouteObserver> routeObserver = diff --git a/lib/i18n/strings.g.dart b/lib/i18n/strings.g.dart index da34753..e4d442a 100644 --- a/lib/i18n/strings.g.dart +++ b/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 28 -/// Strings: 1988 (71 per locale) +/// Strings: 1991 (71 per locale) /// -/// Built on 2025-05-26 at 12:50 UTC +/// Built on 2025-05-26 at 15:39 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index 6187254..10c1b62 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -285,8 +285,10 @@ class TranslationsSettingsWalletEn { final Translations _root; // ignore: unused_field // Translations - String get connect_wallet => 'Connect Wallet (NWC nwc://)'; + String get connect_wallet => 'Connect Wallet (NWC nostr+walletconnect://)'; String get disconnect_wallet => 'Disconnect Wallet'; + String get connect_1tap => '1-Tap Connection'; + String get paste => 'Paste URL'; late final TranslationsSettingsWalletErrorEn error = TranslationsSettingsWalletErrorEn.internal(_root); } @@ -366,6 +368,7 @@ class TranslationsSettingsWalletErrorEn { // Translations String get logged_out => 'Cant connect wallet when logged out'; + String get nwc_auth_event_not_found => 'No wallet auth event found'; } /// Flat map(s) containing all translations. @@ -449,9 +452,12 @@ extension on Translations { case 'settings.profile.nip05': return 'Nostr Address'; case 'settings.profile.lud16': return 'Lightning Address'; case 'settings.profile.error.logged_out': return 'Cant edit profile when logged out'; - case 'settings.wallet.connect_wallet': return 'Connect Wallet (NWC nwc://)'; + case 'settings.wallet.connect_wallet': return 'Connect Wallet (NWC nostr+walletconnect://)'; case 'settings.wallet.disconnect_wallet': return 'Disconnect Wallet'; + case 'settings.wallet.connect_1tap': return '1-Tap Connection'; + case 'settings.wallet.paste': return 'Paste URL'; case 'settings.wallet.error.logged_out': return 'Cant connect wallet when logged out'; + case 'settings.wallet.error.nwc_auth_event_not_found': return 'No wallet auth event found'; case 'login.username': return 'Username'; case 'login.amber': return 'Login with Amber'; case 'login.key': return 'Login with Key'; diff --git a/lib/i18n/translated/en.i18n.yaml b/lib/i18n/translated/en.i18n.yaml index e473cd8..c1c814e 100644 --- a/lib/i18n/translated/en.i18n.yaml +++ b/lib/i18n/translated/en.i18n.yaml @@ -120,10 +120,13 @@ settings: error: logged_out: Cant edit profile when logged out wallet: - connect_wallet: Connect Wallet (NWC nwc://) + connect_wallet: Connect Wallet (NWC nostr+walletconnect://) disconnect_wallet: Disconnect Wallet + connect_1tap: 1-Tap Connection + paste: Paste URL error: logged_out: Cant connect wallet when logged out + nwc_auth_event_not_found: No wallet auth event found login: username: "Username" amber: Login with Amber diff --git a/lib/pages/settings_wallet.dart b/lib/pages/settings_wallet.dart index aeb89ab..5d34a5a 100644 --- a/lib/pages/settings_wallet.dart +++ b/lib/pages/settings_wallet.dart @@ -1,5 +1,14 @@ +import 'dart:developer' as developer; +import 'dart:io'; + +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk/shared/nips/nip01/key_pair.dart'; +import 'package:protocol_handler/protocol_handler.dart'; +import 'package:url_launcher/url_launcher.dart'; import 'package:zap_stream_flutter/const.dart'; import 'package:zap_stream_flutter/i18n/strings.g.dart'; import 'package:zap_stream_flutter/login.dart'; @@ -13,16 +22,93 @@ class SettingsWalletPage extends StatefulWidget { State createState() => _Inner(); } -class _Inner extends State { +const nwaHandlerUrl = "zswc://handler"; + +class _Inner extends State with ProtocolListener { late final TextEditingController _uri; String? _error; + KeyPair? _nwaKey; + @override void initState() { _uri = TextEditingController(); + protocolHandler.addListener(this); super.initState(); } + @override + void dispose() { + protocolHandler.removeListener(this); + super.dispose(); + } + + @override + void onProtocolUrlReceived(String url) async { + developer.log("NWA: $url"); + + if (url == nwaHandlerUrl && _nwaKey != null) { + final walletInfos = ndk.requests + .query( + filters: [ + Filter(kinds: [13194], pTags: [_nwaKey!.publicKey], limit: 5), + ], + explicitRelays: nwcRelays, + ) + .stream + .timeout(Duration(seconds: 15)); + + final walletInfo = + (await walletInfos.toList()) + .sortedBy((e) => e.createdAt) + .reversed + .firstOrNull; + if (walletInfo == null) { + setState(() { + _error = t.settings.wallet.error.nwc_auth_event_not_found; + }); + return; + } else { + final nwcUrl = Uri( + scheme: "nostr+walletconnect", + host: walletInfo.pubKey, + queryParameters: {"relay": nwcRelays, "secret": _nwaKey!.privateKey}, + ); + _setWallet(WalletConfig(type: WalletType.nwc, data: nwcUrl.toString())); + } + } + } + + Future _start1TapFlow() async { + final key = Bip340.generatePrivateKey(); + final url = Uri( + scheme: "nostr+walletauth", + host: key.publicKey, + queryParameters: { + "relay": nwcRelays, + "name": "zap.stream", + "request_methods": "pay_invoice", + "icon": "https://zap.stream/logo.png", + "return_to": nwaHandlerUrl, + }, + ); + setState(() { + _error = null; + _nwaKey = key; + }); + await launchUrl(url, mode: LaunchMode.externalApplication); + } + + _setWallet(WalletConfig? cfg) { + loginData.value = LoginAccount( + type: loginData.value!.type, + pubkey: loginData.value!.pubkey, + privateKey: loginData.value!.privateKey, + signerRelays: loginData.value!.signerRelays, + wallet: cfg, + ); + } + @override Widget build(BuildContext context) { final pubkey = ndk.accounts.getPublicKey(); @@ -33,11 +119,34 @@ class _Inner extends State { builder: (context, state, child) { if (state?.wallet == null) { return Column( - spacing: 8, + spacing: 16, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (Platform.isAndroid) ...[ + Text( + t.settings.wallet.connect_1tap, + style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold), + ), + BasicButton.text( + t.button.connect, + onTap: (context) { + _start1TapFlow().onError((e, _) { + setState(() { + _error = e.toString(); + }); + }); + }, + ), + ], + Text( + "Paste URL", + style: TextStyle(fontSize: 26, fontWeight: FontWeight.bold), + ), TextField( controller: _uri, - decoration: InputDecoration(labelText: t.settings.wallet.connect_wallet), + decoration: InputDecoration( + labelText: t.settings.wallet.connect_wallet, + ), ), BasicButton.text( t.button.connect, @@ -48,13 +157,8 @@ class _Inner extends State { type: WalletType.nwc, data: _uri.text, ); - loginData.value = LoginAccount( - type: loginData.value!.type, - pubkey: loginData.value!.pubkey, - privateKey: loginData.value!.privateKey, - signerRelays: loginData.value!.signerRelays, - wallet: cfg, - ); + _setWallet(cfg); + if (context.mounted) { context.pop(); } @@ -73,13 +177,7 @@ class _Inner extends State { return BasicButton.text( t.settings.wallet.disconnect_wallet, onTap: (context) { - loginData.value = LoginAccount( - type: loginData.value!.type, - pubkey: loginData.value!.pubkey, - privateKey: loginData.value!.privateKey, - signerRelays: loginData.value!.signerRelays, - wallet: null, - ); + _setWallet(null); if (context.mounted) { context.pop(); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8a74080..172e45c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -14,6 +14,7 @@ import flutter_secure_storage_macos import objectbox_flutter_libs import package_info_plus import path_provider_foundation +import protocol_handler_macos import share_plus import shared_preferences_foundation import sqflite_darwin @@ -31,6 +32,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ObjectboxFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "ObjectboxFlutterLibsPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + ProtocolHandlerMacosPlugin.register(with: registry.registrar(forPlugin: "ProtocolHandlerMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/pubspec.lock b/pubspec.lock index 2ab4e91..c5de158 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -896,6 +896,54 @@ packages: url: "https://pub.dev" source: hosted version: "3.9.1" + protocol_handler: + dependency: "direct main" + description: + name: protocol_handler + sha256: dc2e2dcb1e0e313c3f43827ec3fa6d98adee6e17edc0c3923ac67efee87479a9 + url: "https://pub.dev" + source: hosted + version: "0.2.0" + protocol_handler_android: + dependency: transitive + description: + name: protocol_handler_android + sha256: "82eb860ca42149e400328f54b85140329a1766d982e94705b68271f6ca73895c" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + protocol_handler_ios: + dependency: transitive + description: + name: protocol_handler_ios + sha256: "0d3a56b8c1926002cb1e32b46b56874759f4dcc8183d389b670864ac041b6ec2" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + protocol_handler_macos: + dependency: transitive + description: + name: protocol_handler_macos + sha256: "6eb8687a84e7da3afbc5660ce046f29d7ecf7976db45a9dadeae6c87147dd710" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + protocol_handler_platform_interface: + dependency: transitive + description: + name: protocol_handler_platform_interface + sha256: "53776b10526fdc25efdf1abcf68baf57fdfdb75342f4101051db521c9e3f3e5b" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + protocol_handler_windows: + dependency: transitive + description: + name: protocol_handler_windows + sha256: d8f3a58938386aca2c76292757392f4d059d09f11439d6d896d876ebe997f2c4 + url: "https://pub.dev" + source: hosted + version: "0.2.0" provider: dependency: transitive description: @@ -1373,6 +1421,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.13.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "21ec76dfc731550fd3e2ce7a33a9ea90b828fdf19a5c3bcf556fa992cfa99852" + url: "https://pub.dev" + source: hosted + version: "1.1.5" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8a6dd43..1c2e184 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: http: ^1.4.0 flutter_local_notifications: ^19.2.1 flutter_dotenv: ^5.2.1 + protocol_handler: ^0.2.0 dependency_overrides: ndk: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index bdb6e1a..1827ee1 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -25,6 +26,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); ObjectboxFlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("ObjectboxFlutterLibsPlugin")); + ProtocolHandlerWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ProtocolHandlerWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index e1a7c66..75a3233 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST firebase_core flutter_secure_storage_windows objectbox_flutter_libs + protocol_handler_windows share_plus url_launcher_windows )