From 38ab231eb1be56999a4e76341455c0b881b0bf24 Mon Sep 17 00:00:00 2001 From: chanderlud Date: Sun, 2 Aug 2026 06:25:38 +0000 Subject: [PATCH 1/4] feat: check for latest GitHub release on launch - Query the latest release tag at startup and compare against the installed version, showing a dialog with a link to the release page when a newer version exists - Add an automatic-update-check toggle and manual check button to settings - Rename the Interface settings section to General and move it first Implements #35 --- lib/app.dart | 16 ++ lib/controllers/preferences_controller.dart | 10 ++ lib/core/utils/dialog_utils.dart | 51 ++++++ lib/core/utils/index.dart | 1 + lib/core/utils/update_checker.dart | 135 ++++++++++++++++ lib/screens/settings/menu.dart | 4 +- lib/screens/settings/sections/general.dart | 152 ++++++++++++++++++ lib/screens/settings/sections/interface.dart | 78 --------- lib/screens/settings/view.dart | 10 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 106 ++++++++++-- pubspec.yaml | 3 + test/core/utils/update_checker_test.dart | 95 +++++++++++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 17 files changed, 576 insertions(+), 98 deletions(-) create mode 100644 lib/core/utils/update_checker.dart create mode 100644 lib/screens/settings/sections/general.dart delete mode 100644 lib/screens/settings/sections/interface.dart create mode 100644 test/core/utils/update_checker_test.dart diff --git a/lib/app.dart b/lib/app.dart index eaa0ce27..705489fa 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart' hide Overlay; import 'package:provider/provider.dart'; import 'package:telepathy/core/theme/app_theme.dart'; +import 'package:telepathy/core/utils/index.dart'; import 'package:telepathy/controllers/index.dart'; import 'package:telepathy/screens/home/home_page.dart'; @@ -26,6 +27,21 @@ class _TelepathyAppState extends State with WindowListener { super.initState(); windowManager.addListener(this); _initWindow(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkForUpdates(); + }); + } + + Future _checkForUpdates() async { + if (!context.read().automaticUpdateChecks) { + return; + } + + final update = (await UpdateChecker().check()).availableUpdate; + final navigator = navigatorKey.currentState; + if (update != null && navigator?.mounted == true) { + await showUpdateAvailableDialog(navigator!.context, update); + } } Future _initWindow() async { diff --git a/lib/controllers/preferences_controller.dart b/lib/controllers/preferences_controller.dart index a9ebb8c2..c969d4e1 100644 --- a/lib/controllers/preferences_controller.dart +++ b/lib/controllers/preferences_controller.dart @@ -12,12 +12,16 @@ class PreferencesController with ChangeNotifier { late bool efficiencyMode; + late bool automaticUpdateChecks; + PreferencesController({required this.options}); Future init() async { playCustomRingtones = await options.getBool('playCustomRingtones') ?? true; customRingtoneFile = await options.getString('customRingtoneFile'); efficiencyMode = await options.getBool('efficiencyMode') ?? false; + automaticUpdateChecks = + await options.getBool('automaticUpdateChecks') ?? true; notifyListeners(); } @@ -44,4 +48,10 @@ class PreferencesController with ChangeNotifier { await options.setBool('efficiencyMode', enabled); notifyListeners(); } + + Future updateAutomaticUpdateChecks(bool enabled) async { + automaticUpdateChecks = enabled; + await options.setBool('automaticUpdateChecks', enabled); + notifyListeners(); + } } diff --git a/lib/core/utils/dialog_utils.dart b/lib/core/utils/dialog_utils.dart index 43961e3c..80edc7d5 100644 --- a/lib/core/utils/dialog_utils.dart +++ b/lib/core/utils/dialog_utils.dart @@ -2,7 +2,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:telepathy/core/rust/types.dart'; +import 'package:telepathy/core/utils/console.dart'; +import 'package:telepathy/core/utils/update_checker.dart'; import 'package:telepathy/widgets/common/index.dart'; +import 'package:url_launcher/url_launcher.dart'; /// Shows an error modal. void showErrorDialog(BuildContext context, String title, String errorMessage) { @@ -28,6 +31,54 @@ void showErrorDialog(BuildContext context, String title, String errorMessage) { ); } +Future showUpdateAvailableDialog( + BuildContext context, + AvailableUpdate update, +) { + return showDialog( + context: context, + builder: (BuildContext dialogContext) { + return AlertDialog( + title: const Text('Update Available'), + content: Text( + 'Telepathy ${update.version} is available. ' + 'Open the release page to download it.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Later'), + ), + TextButton( + onPressed: () async { + try { + final launched = await launchUrl( + update.releaseUrl, + mode: LaunchMode.externalApplication, + ); + if (launched) { + return; + } + DebugConsole.warn( + 'Could not open release URL: ${update.releaseUrl}', + ); + } catch (error) { + DebugConsole.warn( + 'Could not open release URL ${update.releaseUrl}: $error', + ); + } + }, + child: const Text('View Release'), + ), + ], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ); + }, + ); +} + /// Prompts the user to accept an incoming call. Future acceptCallPrompt( BuildContext context, diff --git a/lib/core/utils/index.dart b/lib/core/utils/index.dart index 099a5fbd..fe92954b 100644 --- a/lib/core/utils/index.dart +++ b/lib/core/utils/index.dart @@ -7,3 +7,4 @@ export 'format_utils.dart'; export 'io_shim.dart'; export 'layout_context.dart'; export 'sound_effects.dart'; +export 'update_checker.dart'; diff --git a/lib/core/utils/update_checker.dart b/lib/core/utils/update_checker.dart new file mode 100644 index 00000000..63adea71 --- /dev/null +++ b/lib/core/utils/update_checker.dart @@ -0,0 +1,135 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:telepathy/core/utils/console.dart'; + +class AvailableUpdate { + final String version; + final Uri releaseUrl; + + const AvailableUpdate({required this.version, required this.releaseUrl}); +} + +class UpdateCheckResult { + final AvailableUpdate? availableUpdate; + final String? error; + + const UpdateCheckResult._({this.availableUpdate, this.error}); + + const UpdateCheckResult.upToDate() : this._(); + + const UpdateCheckResult.updateAvailable(AvailableUpdate update) + : this._(availableUpdate: update); + + const UpdateCheckResult.failed(String message) : this._(error: message); + + bool get failed => error != null; +} + +class UpdateChecker { + final http.Client? _client; + final Future Function() _installedVersion; + + static final Uri _latestReleaseUrl = Uri.parse( + 'https://api.github.com/repos/chanderlud/telepathy/releases/latest', + ); + + UpdateChecker({ + http.Client? client, + Future Function()? installedVersion, + }) : _client = client, + _installedVersion = installedVersion ?? _loadInstalledVersion; + + Future check() async { + final client = _client ?? http.Client(); + + try { + final installedVersion = await _installedVersion(); + final response = await client.get( + _latestReleaseUrl, + headers: const { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'Telepathy-Update-Checker', + 'X-GitHub-Api-Version': '2022-11-28', + }, + ).timeout(const Duration(seconds: 10)); + + if (response.statusCode != 200) { + return _failure('GitHub API returned HTTP ${response.statusCode}'); + } + + final body = jsonDecode(response.body); + if (body is! Map) { + return _failure('GitHub API returned an unexpected response'); + } + + final tagName = body['tag_name']; + final releaseUrl = body['html_url']; + if (tagName is! String || releaseUrl is! String) { + return _failure('GitHub release data is missing required fields'); + } + + final parsedReleaseUrl = Uri.tryParse(releaseUrl); + if (parsedReleaseUrl == null || + !parsedReleaseUrl.hasScheme || + !parsedReleaseUrl.hasAuthority) { + return _failure('GitHub release URL is invalid'); + } + + if (!isNewerVersion(tagName, installedVersion)) { + return const UpdateCheckResult.upToDate(); + } + + return UpdateCheckResult.updateAvailable( + AvailableUpdate(version: tagName, releaseUrl: parsedReleaseUrl), + ); + } catch (error) { + return _failure('Update check failed: $error'); + } finally { + if (_client == null) { + client.close(); + } + } + } + + static bool isNewerVersion(String latest, String installed) { + final latestParts = _versionParts(latest); + final installedParts = _versionParts(installed); + + for (var index = 0; index < 3; index++) { + if (latestParts[index] != installedParts[index]) { + return latestParts[index] > installedParts[index]; + } + } + + return false; + } + + static List _versionParts(String version) { + final normalized = version.startsWith('v') || version.startsWith('V') + ? version.substring(1) + : version; + final segments = normalized.split('.'); + + return List.generate(3, (index) { + if (index >= segments.length) { + return 0; + } + + final numericPrefix = RegExp(r'^\d+').firstMatch(segments[index]); + return int.tryParse(numericPrefix?.group(0) ?? '') ?? 0; + }); + } + + UpdateCheckResult _failure(String message) { + DebugConsole.warn(message); + return UpdateCheckResult.failed(message); + } +} + +Future _loadInstalledVersion() async { + final packageInfo = await PackageInfo.fromPlatform(); + return packageInfo.version; +} diff --git a/lib/screens/settings/menu.dart b/lib/screens/settings/menu.dart index 583be4bc..686e2101 100644 --- a/lib/screens/settings/menu.dart +++ b/lib/screens/settings/menu.dart @@ -19,14 +19,14 @@ class SettingsMenu extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildItem(context, SettingsSection.general, 'General'), + const SizedBox(height: 12), _buildItem(context, SettingsSection.audioVideo, 'Audio & Video'), const SizedBox(height: 12), _buildItem(context, SettingsSection.profiles, 'Profiles'), const SizedBox(height: 12), _buildItem(context, SettingsSection.networking, 'Networking'), const SizedBox(height: 12), - _buildItem(context, SettingsSection.interface, 'Interface'), - const SizedBox(height: 12), _buildItem(context, SettingsSection.logs, 'View Log'), if (showOverlayItem) ...[ const SizedBox(height: 12), diff --git a/lib/screens/settings/sections/general.dart b/lib/screens/settings/sections/general.dart new file mode 100644 index 00000000..1330d9ef --- /dev/null +++ b/lib/screens/settings/sections/general.dart @@ -0,0 +1,152 @@ +import 'dart:core'; + +import 'package:flutter/material.dart' hide Overlay; +import 'package:provider/provider.dart'; +import 'package:telepathy/controllers/index.dart'; +import 'package:telepathy/core/utils/index.dart'; +import 'package:telepathy/widgets/common/index.dart'; + +class GeneralSettings extends StatefulWidget { + final BoxConstraints constraints; + + const GeneralSettings({super.key, required this.constraints}); + + @override + GeneralSettingsState createState() => GeneralSettingsState(); +} + +class GeneralSettingsState extends State { + final TextEditingController _primaryColorInput = TextEditingController(); + String? _primaryColorError; + late final InterfaceController _controller; + bool _checkingForUpdates = false; + + @override + void initState() { + super.initState(); + _controller = context.read(); + _primaryColorInput.text = '#${_controller.primaryColor.toRadixString(16)}'; + } + + @override + void dispose() { + _primaryColorInput.dispose(); + super.dispose(); + } + + Future _checkForUpdates() async { + setState(() { + _checkingForUpdates = true; + }); + + final result = await UpdateChecker().check(); + if (!mounted) { + return; + } + + setState(() { + _checkingForUpdates = false; + }); + + final update = result.availableUpdate; + if (update != null) { + await showUpdateAvailableDialog(context, update); + return; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + result.failed + ? 'Could not check for updates. Please try again.' + : 'Telepathy is up to date.', + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final preferencesController = context.watch(); + double width = widget.constraints.maxWidth < 650 + ? widget.constraints.maxWidth + : (widget.constraints.maxWidth - 20) / 2; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'General', + style: TextStyle(fontSize: 20), + ), + const SizedBox(height: 17), + Center( + child: Wrap( + spacing: 20, + runSpacing: 20, + children: [ + SizedBox( + width: width, + child: TextInput( + labelText: 'Primary Color', + controller: _primaryColorInput, + onChanged: (String value) { + int? color = + int.tryParse(value.replaceAll('#', ''), radix: 16); + + if (color == null) { + _primaryColorError = 'Invalid hex color'; + } else { + _primaryColorError = null; + _controller.setPrimaryColor(color); + } + }, + error: _primaryColorError == null + ? null + : Text(_primaryColorError!, + style: const TextStyle(color: Colors.red)), + )), + Button( + text: 'Revert primary color to default', + onPressed: () { + _controller.setPrimaryColor(0xff5538e5); + _primaryColorInput.text = '#ff5538e5'; + }, + width: 200, + height: 25, + ), + SizedBox( + width: width, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Expanded( + child: Text( + 'Check for updates automatically', + style: TextStyle(fontSize: 18), + ), + ), + CustomSwitch( + value: preferencesController.automaticUpdateChecks, + onChanged: + preferencesController.updateAutomaticUpdateChecks, + ), + ], + ), + ), + Button( + text: _checkingForUpdates + ? 'Checking for updates...' + : 'Check for updates', + disabled: _checkingForUpdates, + onPressed: _checkForUpdates, + width: 200, + height: 25, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/screens/settings/sections/interface.dart b/lib/screens/settings/sections/interface.dart deleted file mode 100644 index acd3c8dc..00000000 --- a/lib/screens/settings/sections/interface.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'dart:core'; -import 'package:flutter/material.dart' hide Overlay; -import 'package:provider/provider.dart'; -import 'package:telepathy/controllers/index.dart'; -import 'package:telepathy/widgets/common/index.dart'; - -class InterfaceSettings extends StatefulWidget { - final BoxConstraints constraints; - - const InterfaceSettings({super.key, required this.constraints}); - - @override - InterfaceSettingsState createState() => InterfaceSettingsState(); -} - -class InterfaceSettingsState extends State { - final TextEditingController _primaryColorInput = TextEditingController(); - String? _primaryColorError; - late final InterfaceController _controller; - - @override - void initState() { - super.initState(); - _controller = context.read(); - _primaryColorInput.text = '#${_controller.primaryColor.toRadixString(16)}'; - } - - @override - Widget build(BuildContext context) { - double width = widget.constraints.maxWidth < 650 - ? widget.constraints.maxWidth - : (widget.constraints.maxWidth - 20) / 2; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Wrap( - spacing: 20, - runSpacing: 20, - children: [ - SizedBox( - width: width, - child: TextInput( - labelText: 'Primary Color', - controller: _primaryColorInput, - onChanged: (String value) { - int? color = - int.tryParse(value.replaceAll('#', ''), radix: 16); - - if (color == null) { - _primaryColorError = 'Invalid hex color'; - } else { - _primaryColorError = null; - _controller.setPrimaryColor(color); - } - }, - error: _primaryColorError == null - ? null - : Text(_primaryColorError!, - style: const TextStyle(color: Colors.red)), - )), - Button( - text: 'Revert primary color to default', - onPressed: () { - _controller.setPrimaryColor(0xff5538e5); - _primaryColorInput.text = '#ff5538e5'; - }, - width: 200, - height: 25, - ), - ], - ), - ), - ], - ); - } -} diff --git a/lib/screens/settings/view.dart b/lib/screens/settings/view.dart index 28d158b2..6c0c70cc 100644 --- a/lib/screens/settings/view.dart +++ b/lib/screens/settings/view.dart @@ -7,16 +7,16 @@ import 'package:telepathy/screens/settings/header.dart'; import 'package:telepathy/screens/settings/logs.dart'; import 'package:telepathy/screens/settings/menu.dart'; import 'package:telepathy/screens/settings/sections/audio_video.dart'; -import 'package:telepathy/screens/settings/sections/interface.dart'; +import 'package:telepathy/screens/settings/sections/general.dart'; import 'package:telepathy/screens/settings/sections/networking.dart'; import 'package:telepathy/screens/settings/sections/overlay.dart'; import 'package:telepathy/screens/settings/sections/profiles.dart'; enum SettingsSection { + general, audioVideo, profiles, networking, - interface, logs, overlay, } @@ -32,7 +32,7 @@ class SettingsPage extends StatefulWidget { class SettingsPageState extends State with SingleTickerProviderStateMixin { - SettingsSection _section = SettingsSection.audioVideo; + SettingsSection _section = SettingsSection.general; bool? showMenu; final TextEditingController _searchController = TextEditingController(); @@ -131,8 +131,8 @@ class SettingsPageState extends State const ProfileSettings(), SettingsSection.networking => NetworkSettings( key: _key, constraints: constraints), - SettingsSection.interface => - InterfaceSettings(constraints: constraints), + SettingsSection.general => + GeneralSettings(constraints: constraints), SettingsSection.logs => LogsSettings( searchController: _searchController), SettingsSection.overlay => diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 0949deb8..e885888f 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -10,6 +10,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -25,6 +26,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) super_native_extensions_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "SuperNativeExtensionsPlugin"); super_native_extensions_plugin_register_with_registrar(super_native_extensions_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); g_autoptr(FlPluginRegistrar) window_manager_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "WindowManagerPlugin"); window_manager_plugin_register_with_registrar(window_manager_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index ecfd2cef..4cb4c5f2 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST irondash_engine_context screen_retriever_linux super_native_extensions + url_launcher_linux window_manager ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cdc4614e..53e72847 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,10 +9,12 @@ import device_info_plus import file_picker import flutter_secure_storage_darwin import irondash_engine_context +import package_info_plus import path_provider_foundation import screen_retriever_macos import shared_preferences_foundation import super_native_extensions +import url_launcher_macos import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -20,9 +22,11 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 09e7606d..3ecaa563 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -101,10 +101,10 @@ packages: dependency: transitive description: name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.4.1" checked_yaml: dependency: transitive description: @@ -371,7 +371,7 @@ packages: source: hosted version: "2.3.2" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" @@ -475,26 +475,26 @@ packages: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.19" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" url: "https://pub.dev" source: hosted - version: "0.11.1" + version: "0.13.0" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.0" mime: dependency: transitive description: @@ -527,6 +527,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -935,10 +951,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.11" typed_data: dependency: transitive description: @@ -947,6 +963,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: "direct main" description: @@ -1084,5 +1164,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 8503dc11..aa2d9e1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -31,6 +31,9 @@ dependencies: freezed_annotation: ^3.0.0 window_manager: ^0.5.1 web: ^1.1.1 + http: ^1.6.0 + package_info_plus: ^9.0.1 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: diff --git a/test/core/utils/update_checker_test.dart b/test/core/utils/update_checker_test.dart new file mode 100644 index 00000000..8876c1eb --- /dev/null +++ b/test/core/utils/update_checker_test.dart @@ -0,0 +1,95 @@ +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:telepathy/core/utils/update_checker.dart'; + +void main() { + group('UpdateChecker.check', () { + test('returns release details when GitHub has a newer version', () async { + final checker = UpdateChecker( + client: MockClient((request) async { + expect(request.headers['user-agent'], 'Telepathy-Update-Checker'); + return http.Response( + '{"tag_name":"v2.9.0","html_url":"https://github.com/chanderlud/telepathy/releases/tag/v2.9.0"}', + 200, + ); + }), + installedVersion: () async => '2.8.1', + ); + + final result = await checker.check(); + + expect(result.failed, isFalse); + expect(result.availableUpdate?.version, 'v2.9.0'); + expect( + result.availableUpdate?.releaseUrl, + Uri.parse( + 'https://github.com/chanderlud/telepathy/releases/tag/v2.9.0', + ), + ); + }); + + test('returns up to date when installed version matches', () async { + final checker = UpdateChecker( + client: MockClient( + (_) async => http.Response( + '{"tag_name":"v2.8.1","html_url":"https://github.com/chanderlud/telepathy/releases/tag/v2.8.1"}', + 200, + ), + ), + installedVersion: () async => '2.8.1', + ); + + final result = await checker.check(); + + expect(result.failed, isFalse); + expect(result.availableUpdate, isNull); + }); + + test('returns failure for a GitHub API error', () async { + final checker = UpdateChecker( + client: MockClient((_) async => http.Response('rate limited', 403)), + installedVersion: () async => '2.8.1', + ); + + final result = await checker.check(); + + expect(result.failed, isTrue); + expect(result.availableUpdate, isNull); + }); + + test('returns failure for malformed release data', () async { + final checker = UpdateChecker( + client: + MockClient((_) async => http.Response('{"name":"Latest"}', 200)), + installedVersion: () async => '2.8.1', + ); + + final result = await checker.check(); + + expect(result.failed, isTrue); + expect(result.availableUpdate, isNull); + }); + }); + + group('UpdateChecker.isNewerVersion', () { + test('detects newer major, minor, and patch versions', () { + expect(UpdateChecker.isNewerVersion('v3.0.0', '2.8.1'), isTrue); + expect(UpdateChecker.isNewerVersion('v2.9.0', '2.8.1'), isTrue); + expect(UpdateChecker.isNewerVersion('v2.8.2', '2.8.1'), isTrue); + }); + + test('does not report equal or older versions as newer', () { + expect(UpdateChecker.isNewerVersion('v2.8.1', '2.8.1'), isFalse); + expect(UpdateChecker.isNewerVersion('v2.8.0', '2.8.1'), isFalse); + expect(UpdateChecker.isNewerVersion('v1.12.9', '2.0.0'), isFalse); + }); + + test('pads missing segments and handles odd segments', () { + expect(UpdateChecker.isNewerVersion('v2.9', '2.8.1'), isTrue); + expect(UpdateChecker.isNewerVersion('2.8', '2.8.0'), isFalse); + expect(UpdateChecker.isNewerVersion('v2.8.2-beta.1', '2.8.1'), isTrue); + expect(UpdateChecker.isNewerVersion('release', '0.0.0'), isFalse); + }); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 30254417..2fdb1ed5 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -11,6 +11,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -24,6 +25,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ScreenRetrieverWindowsPluginCApi")); SuperNativeExtensionsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SuperNativeExtensionsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); WindowManagerPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("WindowManagerPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 9b3a055f..9c3f7967 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -8,6 +8,7 @@ list(APPEND FLUTTER_PLUGIN_LIST permission_handler_windows screen_retriever_windows super_native_extensions + url_launcher_windows window_manager ) From 35cb411e3bf2880e20fc6436cbdd9793fe34fa54 Mon Sep 17 00:00:00 2001 From: chanderlud Date: Sun, 2 Aug 2026 14:24:07 +0000 Subject: [PATCH 2/4] fix(android): bump AGP to 8.9.1 and Gradle to 8.11.1 url_launcher and package_info_plus pull androidx.browser:1.9.0 and androidx.core:1.17.0, which require Android Gradle plugin 8.9.1+; AGP 8.9 in turn requires Gradle 8.11.1+. --- android/gradle/wrapper/gradle-wrapper.properties | 2 +- android/settings.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 3c85cfe0..efdcc4ac 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip diff --git a/android/settings.gradle b/android/settings.gradle index 4f520718..b507b943 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -18,7 +18,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.6.0" apply false + id "com.android.application" version "8.9.1" apply false id "org.jetbrains.kotlin.android" version "2.1.0" apply false } From eac35bd3909494cbd32f1c8956448d48ba0c31e5 Mon Sep 17 00:00:00 2001 From: chanderlud Date: Mon, 3 Aug 2026 20:08:55 +0000 Subject: [PATCH 3/4] chore: remove unnecessary io_shim import in app.dart --- lib/app.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/app.dart b/lib/app.dart index abbb65bf..f737150d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -6,7 +6,6 @@ import 'package:telepathy/controllers/index.dart'; import 'package:telepathy/screens/home/home_page.dart'; import 'package:telepathy/core/rust/flutter.dart'; -import 'package:telepathy/core/utils/io_shim.dart'; import 'package:window_manager/window_manager.dart'; final GlobalKey navigatorKey = GlobalKey(); From bff463284df6a9d5284c62995873f42d3c83535b Mon Sep 17 00:00:00 2001 From: chanderlud Date: Mon, 3 Aug 2026 20:08:55 +0000 Subject: [PATCH 4/4] docs: document launch-time update checking pattern --- .../flutter-launch-update-checking.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/solutions/best-practices/flutter-launch-update-checking.md diff --git a/docs/solutions/best-practices/flutter-launch-update-checking.md b/docs/solutions/best-practices/flutter-launch-update-checking.md new file mode 100644 index 00000000..42de1534 --- /dev/null +++ b/docs/solutions/best-practices/flutter-launch-update-checking.md @@ -0,0 +1,139 @@ +--- +title: Launch-Time Update Checking Against GitHub Releases +date: 2026-08-03 +category: docs/solutions/best-practices/ +module: Flutter app lifecycle and settings +problem_type: best_practice +component: development_workflow +severity: low +applies_when: + - "Adding or modifying update-check or version-check behavior" + - "Adding any network call that runs during app startup" + - "Comparing semantic versions across tag formats (v-prefix, pre-release suffixes)" +related_components: + - lib/core/utils/update_checker.dart + - lib/core/utils/dialog_utils.dart + - lib/controllers/preferences_controller.dart + - lib/screens/settings/sections/general.dart + - lib/app.dart +tags: [update-check, version-compare, github-releases, startup, preferences, flutter] +--- + +# Launch-Time Update Checking Against GitHub Releases + +## Context + +Telepathy needed a way to notify desktop users when a newer release exists +(GitHub issue #35). Update checking is a startup network call, which makes it +easy to get wrong in familiar ways: blocking first paint on an HTTP request, +crashing the app when the network is down or GitHub rate-limits, and +misfiring on version strings that carry `v` prefixes or pre-release suffixes. + +## Guidance + +The implementation has four parts, each with a deliberate shape: + +**1. A result type, never a thrown exception** (`lib/core/utils/update_checker.dart`). +`UpdateChecker.check()` returns an `UpdateCheckResult` that is one of +`upToDate`, `updateAvailable`, or `failed`. Every failure path — non-200 +status, malformed JSON, missing `tag_name`/`html_url`, invalid release URL, +timeout, transport error — is funnelled into `UpdateCheckResult.failed` and +logged via `DebugConsole.warn`. The UI can then stay simple: show the dialog +only when `availableUpdate != null`, and the launch path ignores failures +entirely. + +**2. Injectable seams for the network and the installed version.** +`UpdateChecker` takes an optional `http.Client` and an optional +`installedVersion` callback (defaulting to `package_info_plus`). Tests inject +a `MockClient` and a fixed version string, so the full check logic — +parsing, comparison, failure mapping — is covered without real network or +platform channels. This is the only mock-worthy seam here: the GitHub API is +an external service. + +**3. Tolerant three-segment version comparison.** +`UpdateChecker.isNewerVersion` strips a leading `v`/`V`, pads missing +segments with zero, and takes the numeric prefix of each segment (so +`v2.8.2-beta.1` compares as `2.8.2`). It compares major, minor, patch in +order and returns true only when the release is strictly newer. This avoids +pulling in a full semver package for a comparison the project's own tag +format controls. + +**4. A preference-gated, post-frame launch hook.** +In `lib/app.dart`, `_TelepathyAppState.initState` schedules the check with +`WidgetsBinding.instance.addPostFrameCallback`, so first paint never waits on +HTTP. The check reads `PreferencesController.automaticUpdateChecks` +(persisted via the options store, default `true`) and shows +`showUpdateAvailableDialog` through the global `navigatorKey`, since the +post-frame `context` is not reliable for navigation. The dialog's "View +Release" action opens the release page with `url_launcher` in +`LaunchMode.externalApplication` mode. The same checker backs a manual +"check now" button and an opt-out toggle in Settings → General +(`lib/screens/settings/sections/general.dart`). + +## Why This Matters + +Startup is the worst place for unguarded I/O: a hung or slow request delays +first paint for every user on every launch, and an unhandled exception in +`initState` can break the whole app for users on flaky networks. Routing +every failure into a result type keeps the launch path crash-proof by +construction rather than by try/catch discipline at each call site. The +10-second timeout on the request bounds the worst case. + +The manual settings button reuses the same `UpdateChecker`, so the opt-out +preference, the dialog, and the comparison logic have exactly one +implementation — a fix to any of them applies everywhere. + +## When to Apply + +- Any network call scheduled from `initState`: use a post-frame callback, a + persisted opt-out preference, and a result type instead of exceptions. +- Any version comparison in this repo: reuse `UpdateChecker.isNewerVersion` + rather than writing a new parser — GitHub tags carry a `v` prefix while + `package_info_plus` reports the bare version. +- Any external-URL action: prefer `url_launcher` with + `LaunchMode.externalApplication` and handle a `false` return (log, don't + throw). + +## Examples + +Launch hook in `lib/app.dart`: + +```dart +WidgetsBinding.instance.addPostFrameCallback((_) { + _checkForUpdates(); +}); + +Future _checkForUpdates() async { + if (!context.read().automaticUpdateChecks) { + return; + } + + final update = (await UpdateChecker().check()).availableUpdate; + final navigator = navigatorKey.currentState; + if (update != null && navigator?.mounted == true) { + await showUpdateAvailableDialog(navigator!.context, update); + } +} +``` + +Test seam in `test/core/utils/update_checker_test.dart`: + +```dart +final checker = UpdateChecker( + client: MockClient((request) async { + return http.Response( + '{"tag_name":"v2.9.0","html_url":"https://github.com/chanderlud/telepathy/releases/tag/v2.9.0"}', + 200, + ); + }), + installedVersion: () async => '2.8.1', +); +``` + +## Related + +- GitHub issue #35 ("Latest version/update check") — the originating request +- PR #71 (`feature/version-checking`) — the implementation, open as of this writing +- `docs/solutions/conventions/platform-launch-smoke-ci-2026-08-03.md` — the + launch-smoke CI convention; an update dialog shown on launch is startup + behavior the smoke tests exercise