From 06f023aadaca51270100f402e7ba2a65492fcfff Mon Sep 17 00:00:00 2001 From: flemming-it Date: Wed, 15 Jul 2026 10:46:22 +0200 Subject: [PATCH] fix: survive corrupt preferences at startup; drop CocoaPods leftovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A black window on launch, no error anywhere: main() awaited loadPersistedEndpoint before the first frame, and SharedPreferences.getBool threw 'int is not a subtype of bool?' — the store is writable from outside the app and hub.secure had been written as int 0. Pref reads now go through defensive typed helpers (int coerces to bool, wrong types fall back to defaults), and every pre-frame restore step is failure-isolated: a broken store can cost a preference, never the first frame. Regression tests stage the corrupt store (the exact observed value and worse). Also removes the CocoaPods leftovers from the macOS project (Podfile, [CP] script phases, Pods framework references, xcconfig includes): the project builds via Swift Package Manager, and the dual wiring ran both dependency managers on every build — Flutter's persistent 'removing CocoaPods will improve build time' warning. Verified: clean profile build produces a launchable bundle (plugins statically linked via SwiftPM), suite green, analyze clean. Signed-off-by: flemming-it --- CHANGELOG.md | 17 ++++++ lib/data/hub.dart | 35 ++++++++++-- lib/main.dart | 38 +++++++++++-- macos/Flutter/Flutter-Debug.xcconfig | 1 - macos/Flutter/Flutter-Release.xcconfig | 1 - macos/Podfile | 42 -------------- macos/Podfile.lock | 16 ------ macos/Runner.xcodeproj/project.pbxproj | 78 -------------------------- test/pref_corruption_test.dart | 50 +++++++++++++++++ 9 files changed, 130 insertions(+), 148 deletions(-) delete mode 100644 macos/Podfile delete mode 100644 macos/Podfile.lock create mode 100644 test/pref_corruption_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index c92cf78..7cb0911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ version + `kStudioVersion` in `lib/main.dart` stay in lockstep. ## Unreleased +### Fixed (startup + build time) + +- **Black window on launch.** A corrupt preference value (`hub.secure` + stored as int `0` by an external `defaults write`) made + `SharedPreferences.getBool` throw inside `main()` before the first + frame — the app sat as a black window with no visible error. Pref + reads now tolerate wrong types (int coerces to bool, garbage falls + back to the default), and every pre-frame restore step is + failure-isolated so nothing can prevent `runApp` again. Regression + tests reproduce the corrupt store. +- **CocoaPods integration removed** (macOS). The project builds via + Swift Package Manager; the leftover Pods wiring (Podfile, `[CP]` + script phases, Pods framework refs, xcconfig includes) made every + build run both dependency managers — exactly what Flutter's + own "will improve the project's build time" warning was about. + + ### Added - **Settings → Security shows the hub's auth policy.** New panel diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 46bf27a..68a149e 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -106,14 +106,39 @@ class HubService { /// from "caller passed null to drop the token". static const Object _unset = Object(); + /// Defensive typed preference reads. `shared_preferences` THROWS + /// on a type mismatch — and the store is writable from outside + /// the app (`defaults write` puts an int where a bool belongs). + /// A corrupt preference must never take the app down with it + /// (a thrown startup read = black window before the first + /// frame); the worst allowed outcome is the built-in default. + static String? _prefString(SharedPreferences prefs, String key) { + final v = prefs.get(key); + return v is String ? v : null; + } + + static int? _prefInt(SharedPreferences prefs, String key) { + final v = prefs.get(key); + if (v is int) return v; + if (v is bool) return v ? 1 : 0; + return null; + } + + static bool? _prefBool(SharedPreferences prefs, String key) { + final v = prefs.get(key); + if (v is bool) return v; + if (v is int) return v != 0; + return null; + } + /// Read persisted endpoint + auth token, then reconnect. /// Called once at app start; safe to call again after the /// operator updates the token in Settings. Future loadPersistedEndpoint() async { final prefs = await SharedPreferences.getInstance(); - final host = prefs.getString(_kHostKey); - final port = prefs.getInt(_kPortKey); - final secure = prefs.getBool(_kSecureKey); + final host = _prefString(prefs, _kHostKey); + final port = _prefInt(prefs, _kPortKey); + final secure = _prefBool(prefs, _kSecureKey); final token = await HubAuthToken.read(); // 1) An endpoint the operator explicitly chose in Settings wins. @@ -202,7 +227,7 @@ class HubService { /// initial app startup. Defaults to system. Future loadThemeMode() async { final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_kThemeKey); + final raw = _prefString(prefs, _kThemeKey); return ThemeModeValue.fromWire(raw) ?? ThemeModeValue.system; } @@ -217,7 +242,7 @@ class HubService { /// English; the sidebar toggle flips to German on demand. Future loadLocale() async { final prefs = await SharedPreferences.getInstance(); - final raw = prefs.getString(_kLocaleKey); + final raw = _prefString(prefs, _kLocaleKey); if (raw == 'de') return const Locale('de'); return const Locale('en'); } diff --git a/lib/main.dart b/lib/main.dart index 335c958..66e2832 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -34,16 +34,44 @@ import 'widgets/widgets.dart'; /// and quick-glance proof that you're seeing the current build. const String kStudioVersion = '0.70.0'; +/// Run one pre-frame restore step, absorbing any failure. Everything +/// before `runApp` is a black window waiting to happen: an exception +/// here (a corrupt preference store, an unreadable file) used to kill +/// startup with no visible error at all. Each step degrades to its +/// default instead; the app MUST reach the first frame. +Future _restoreOr(T fallback, Future Function() step) async { + try { + return await step(); + } catch (e) { + debugPrint('startup restore step failed (using default): $e'); + return fallback; + } +} + Future main() async { WidgetsFlutterBinding.ensureInitialized(); // Restore the persisted endpoint, theme mode, locale, and // (when installed) the operator's theme-plugin choice // before the first frame so nothing flickers on startup. - await HubService.instance.loadPersistedEndpoint(); - await SystemActions.loadFaiBinaryOverride(); - final themeMode = await HubService.instance.loadThemeMode(); - final locale = await HubService.instance.loadLocale(); - final themePlugin = await loadActiveThemePlugin(); + // Every step is failure-isolated: one broken store must not + // stop the others, and nothing here may prevent runApp. + await _restoreOr(null, () async { + await HubService.instance.loadPersistedEndpoint(); + return null; + }); + await _restoreOr(null, () async { + await SystemActions.loadFaiBinaryOverride(); + return null; + }); + final themeMode = await _restoreOr( + ThemeModeValue.system, + () => HubService.instance.loadThemeMode(), + ); + final locale = await _restoreOr( + const Locale('en'), + () => HubService.instance.loadLocale(), + ); + final themePlugin = await _restoreOr(null, loadActiveThemePlugin); runApp( StudioApp( initialThemeMode: themeMode, diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig index 4b81f9b..c2efd0b 100644 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig index 5caa9d1..c2efd0b 100644 --- a/macos/Flutter/Flutter-Release.xcconfig +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Podfile b/macos/Podfile deleted file mode 100644 index ff5ddb3..0000000 --- a/macos/Podfile +++ /dev/null @@ -1,42 +0,0 @@ -platform :osx, '10.15' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -target 'Runner' do - use_frameworks! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/macos/Podfile.lock b/macos/Podfile.lock deleted file mode 100644 index 3f39e3f..0000000 --- a/macos/Podfile.lock +++ /dev/null @@ -1,16 +0,0 @@ -PODS: - - FlutterMacOS (1.0.0) - -DEPENDENCIES: - - FlutterMacOS (from `Flutter/ephemeral`) - -EXTERNAL SOURCES: - FlutterMacOS: - :path: Flutter/ephemeral - -SPEC CHECKSUMS: - FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - -PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 - -COCOAPODS: 1.17.0 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index b6babe3..2c4e9dd 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,9 +27,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 370E355F35F0FAEC7D04F94B /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5D6BC1A8D7E37B926CF5933E /* Pods_Runner.framework */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; - E1309159C1F5025EF6B9821D /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 770B980D8F15B767C49B23E5 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -63,7 +61,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 1C93D4763670B5002EC24A25 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; @@ -80,16 +77,9 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 5D6BC1A8D7E37B926CF5933E /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 770B980D8F15B767C49B23E5 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 824F23C5F5879DEACA815F19 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - DD47B0891752DBBA76ABCFB5 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - E22C4A447E85951E27417C91 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - F7EFCA08C84A46B6188E5963 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - FB500439F345FA03D73E5AB2 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -97,7 +87,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - E1309159C1F5025EF6B9821D /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -106,7 +95,6 @@ buildActionMask = 2147483647; files = ( 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - 370E355F35F0FAEC7D04F94B /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -140,7 +128,6 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, - 4C343B8693BC75A616AA05F5 /* Pods */, ); sourceTree = ""; }; @@ -189,25 +176,9 @@ path = Runner; sourceTree = ""; }; - 4C343B8693BC75A616AA05F5 /* Pods */ = { - isa = PBXGroup; - children = ( - F7EFCA08C84A46B6188E5963 /* Pods-Runner.debug.xcconfig */, - E22C4A447E85951E27417C91 /* Pods-Runner.release.xcconfig */, - FB500439F345FA03D73E5AB2 /* Pods-Runner.profile.xcconfig */, - DD47B0891752DBBA76ABCFB5 /* Pods-RunnerTests.debug.xcconfig */, - 1C93D4763670B5002EC24A25 /* Pods-RunnerTests.release.xcconfig */, - 824F23C5F5879DEACA815F19 /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( - 5D6BC1A8D7E37B926CF5933E /* Pods_Runner.framework */, - 770B980D8F15B767C49B23E5 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -219,7 +190,6 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 51FEB1BF98FD7036ADA63936 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -238,7 +208,6 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 546C72FC3468AB7FBBCFD16E /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, @@ -370,50 +339,6 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 51FEB1BF98FD7036ADA63936 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 546C72FC3468AB7FBBCFD16E /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -465,7 +390,6 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = DD47B0891752DBBA76ABCFB5 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -480,7 +404,6 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1C93D4763670B5002EC24A25 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -495,7 +418,6 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 824F23C5F5879DEACA815F19 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/test/pref_corruption_test.dart b/test/pref_corruption_test.dart new file mode 100644 index 0000000..495b5c3 --- /dev/null +++ b/test/pref_corruption_test.dart @@ -0,0 +1,50 @@ +// Startup must survive a corrupt preference store. The store is +// writable from outside the app (`defaults write` stores an int +// where a bool belongs); shared_preferences then THROWS on the +// typed getter, and before the hardening that exception escaped +// from main() before the first frame — a black window with no +// visible error (observed live on 2026-07-15: `hub.secure = 0`). + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_studio/data/hub.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('loadPersistedEndpoint tolerates an int where a bool belongs', () async { + SharedPreferences.setMockInitialValues({ + 'hub.host': '127.0.0.1', + 'hub.port': 50051, + 'hub.secure': 0, // the corrupt value that killed startup + }); + // Must not throw; the int coerces to false. + await HubService.instance.loadPersistedEndpoint(); + expect(HubService.instance.currentEndpoint.host, '127.0.0.1'); + expect(HubService.instance.currentEndpoint.port, 50051); + expect(HubService.instance.currentEndpoint.secure, false); + }); + + test('loadPersistedEndpoint tolerates wholesale wrong types', () async { + SharedPreferences.setMockInitialValues({ + 'hub.host': 12345, // wrong type → treated as absent + 'hub.port': 'not-a-port', // wrong type → treated as absent + 'hub.secure': 'yes', // wrong type → treated as absent + }); + await HubService.instance.loadPersistedEndpoint(); + // No explicit endpoint restored → discovery/default path; the + // call simply must not throw. + }); + + test('theme + locale loaders tolerate wrong types', () async { + SharedPreferences.setMockInitialValues({ + 'theme.mode': 42, + 'locale.code': true, + }); + final mode = await HubService.instance.loadThemeMode(); + final locale = await HubService.instance.loadLocale(); + expect(mode, ThemeModeValue.system); + expect(locale.languageCode, 'en'); + }); +}