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'); + }); +}