diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b76ece..477a860 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -### Added - -- **`MagicVaultService` takes `macOsUsesDataProtectionKeychain`, so a macOS build with no signing identity can store a secret at all.** macOS has two keychains and the service previously passed no `mOptions`, which left it on `flutter_secure_storage`'s default of the data protection one (`macos_options.dart:24`). That keychain requires the `keychain-access-groups` entitlement, the entitlement is restricted and therefore forces a signed build with an App ID, and on a build with no certificate installed **every write fails** with `PlatformException(-34018, errSecMissingEntitlement)`; measured on a consumer app's own build. Passing `false` moves the items to the legacy login keychain, which needs no entitlement. Note that `false` does not set `kSecUseDataProtectionKeychain` to false, it OMITS the key from the query entirely (`flutter_secure_storage_darwin` `FlutterSecureStorage.swift:227-231`, guarded by `params.usesDataProtectionKeychain`), which is the mechanism by which the item lands in the other keychain rather than a flag the keychain reads. - - **The default is `true`, which is what every existing build already does, and it stays that way because there is no migration between the two keychains in either direction.** An item written to one is invisible from the other, and every caller reads that miss as "never stored" rather than as an error: `Crypt.encryptWithDeviceKey` generates a fresh device key on a null read (`crypt.dart:141-147`), making everything encrypted under the old one permanently unreadable while the old key sits unreachable in the other keychain, and `BaseGuard` loses the stored token the same way and logs the user out. Flip the key once, before the app has stored anything; do not reach for it to clear a `-34018` on a build that has been storing secrets, and read `doc/security/vault.md` for how to move existing items across. - - `VaultServiceProvider` reads it from `security.vault.macos_data_protection_keychain`, inside the singleton factory rather than beside it, so a test or a hand-registered provider that sets the key afterwards is still read. macOS also now gets `first_unlock_this_device` accessibility, rather than the package default `unlocked` (`apple_options.dart:72`), for the same reason iOS already passes `first_unlock`: a read while the screen is locked, such as a refresh on launch before anyone has touched the machine, succeeds under the former and fails under the latter, and the `ThisDeviceOnly` half keeps the item off a backup restored onto a different Mac. `kSecAttrAccessible` is a data protection keychain attribute, so that class is inert once the key above is `false`. The iOS and Android options are unchanged. (`lib/src/security/magic_vault_service.dart`, `lib/src/security/vault_service_provider.dart`) - -- **`FakeVaultService.throwOnGet`, `.throwOnPut`, `.throwOnRemove` and `.throwOnFlush`, so a consumer can test its own vault-failure branch.** Every prior override was a no-throw body over an in-memory map, so nothing exercised the path a real keychain failure takes through `MagicVaultService`, which wraps a `PlatformException` as `MagicVaultException` on all four operations. `throwOnRemove` is the one a sign-out needs: a consumer that deletes the stored credential first and clears its in-memory session afterwards has two branches through one keychain call, and the failing one decides whether the user is told the secret is still on the device or is shown a sign-out that did nothing. All four default to a `MagicVaultException` and accept a custom error, all four are cleared by `reset()`, and each only affects its own operation: a throw armed on `get` does not touch `put`, and one armed on `remove` does not touch `flush`. The throw is armed for every key, so a test needing one key to fail while its neighbours succeed still needs its own subclass; a key filter would have to record the attempt before the throw, and that would make `assertWritten` pass for a `put` that threw. (`lib/src/testing/fake_vault_service.dart`) +## [0.0.10] - 2026-09-11 ### BREAKING @@ -28,6 +20,14 @@ All notable changes to this project will be documented in this file. ### Added +- **`MagicVaultService` takes `macOsUsesDataProtectionKeychain`, so a macOS build with no signing identity can store a secret at all.** macOS has two keychains and the service previously passed no `mOptions`, which left it on `flutter_secure_storage`'s default of the data protection one (`macos_options.dart:24`). That keychain requires the `keychain-access-groups` entitlement, the entitlement is restricted and therefore forces a signed build with an App ID, and on a build with no certificate installed **every write fails** with `PlatformException(-34018, errSecMissingEntitlement)`; measured on a consumer app's own build. Passing `false` moves the items to the legacy login keychain, which needs no entitlement. Note that `false` does not set `kSecUseDataProtectionKeychain` to false, it OMITS the key from the query entirely (`flutter_secure_storage_darwin` `FlutterSecureStorage.swift:227-231`, guarded by `params.usesDataProtectionKeychain`), which is the mechanism by which the item lands in the other keychain rather than a flag the keychain reads. + + **The default is `true`, which is what every existing build already does, and it stays that way because there is no migration between the two keychains in either direction.** An item written to one is invisible from the other, and every caller reads that miss as "never stored" rather than as an error: `Crypt.encryptWithDeviceKey` generates a fresh device key on a null read (`crypt.dart:141-147`), making everything encrypted under the old one permanently unreadable while the old key sits unreachable in the other keychain, and `BaseGuard` loses the stored token the same way and logs the user out. Flip the key once, before the app has stored anything; do not reach for it to clear a `-34018` on a build that has been storing secrets, and read `doc/security/vault.md` for how to move existing items across. + + `VaultServiceProvider` reads it from `security.vault.macos_data_protection_keychain`, inside the singleton factory rather than beside it, so a test or a hand-registered provider that sets the key afterwards is still read. macOS also now gets `first_unlock_this_device` accessibility, rather than the package default `unlocked` (`apple_options.dart:72`), for the same reason iOS already passes `first_unlock`: a read while the screen is locked, such as a refresh on launch before anyone has touched the machine, succeeds under the former and fails under the latter, and the `ThisDeviceOnly` half keeps the item off a backup restored onto a different Mac. `kSecAttrAccessible` is a data protection keychain attribute, so that class is inert once the key above is `false`. The iOS and Android options are unchanged. (`lib/src/security/magic_vault_service.dart`, `lib/src/security/vault_service_provider.dart`) + +- **`FakeVaultService.throwOnGet`, `.throwOnPut`, `.throwOnRemove` and `.throwOnFlush`, so a consumer can test its own vault-failure branch.** Every prior override was a no-throw body over an in-memory map, so nothing exercised the path a real keychain failure takes through `MagicVaultService`, which wraps a `PlatformException` as `MagicVaultException` on all four operations. `throwOnRemove` is the one a sign-out needs: a consumer that deletes the stored credential first and clears its in-memory session afterwards has two branches through one keychain call, and the failing one decides whether the user is told the secret is still on the device or is shown a sign-out that did nothing. All four default to a `MagicVaultException` and accept a custom error, all four are cleared by `reset()`, and each only affects its own operation: a throw armed on `get` does not touch `put`, and one armed on `remove` does not touch `flush`. The throw is armed for every key, so a test needing one key to fail while its neighbours succeed still needs its own subclass; a key filter would have to record the attempt before the throw, and that would make `assertWritten` pass for a `put` that threw. (`lib/src/testing/fake_vault_service.dart`) + - **`MagicSelector` rebuilds one subtree when one part of a controller changes.** `refreshUI()` notifies every listener and `MagicStatefulViewState` answers with `setState` on the whole view, which is the right default and stops being cheap on a screen where one field changes often and most of the screen does not care: a consumer measured one keystroke in a search field rebuilding 220 styled containers. `MagicBuilder` could not help, because it needs a `ValueListenable` and a controller is a `ChangeNotifier`. The selector caches the widget its builder returned and, while the selected value compares equal, returns that same instance, so `Element.updateChild` short circuits on `child.widget == newWidget` and never descends. Returning an identical instance rather than skipping a `setState` is what makes it work under a parent that rebuilds anyway. Two rules follow: `builder` must be a pure function of the selected value (select a record to watch several fields), and equality is plain `==`, so a selector returning a freshly built `List` never matches its own cache. Deep comparison is deliberately not used, because walking a ten thousand element list per keystroke costs more than the rebuild it prevents. (`lib/src/ui/magic_selector.dart`) - **`MagicPaginator.isRefreshing` and `.isLoadingMore`, because a list has three loading states and one flag cannot carry them.** A first load shows a skeleton, a refresh keeps the rows the reader is already looking at, and a next page puts a footer under the last row. Read off `isLoading` alone the second and third are indistinguishable, so a screen either blanks itself on every filter change or grows a footer promising a page nothing asked for. Both are false on a first load (nothing on screen to preserve, nothing being appended) and all three are false once the request lands. The distinction only exists DURING a request, which is why `_isReset` is set beside `_isLoading` and before the notification rather than derived afterwards: by the time a caller can await the future there is nothing left to tell apart. One window is documented rather than changed: a `refresh()` deferred behind an in-flight `loadMore()` keeps reporting `isLoadingMore` until that page lands, which is what is happening on the wire and the only path where the flags follow the request rather than the caller's most recent ask. (`lib/src/http/magic_paginator.dart`) diff --git a/example/pubspec.lock b/example/pubspec.lock index 08de40c..e690e84 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -595,7 +595,7 @@ packages: path: ".." relative: true source: path - version: "0.0.9" + version: "0.0.10" matcher: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 1e80e1c..8c7c05e 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -36,7 +36,7 @@ dependencies: cupertino_icons: ^1.0.9 magic: path: .. - version: 0.0.9 + version: 0.0.10 fluttersdk_artisan: ^0.0.14 dev_dependencies: diff --git a/pubspec.yaml b/pubspec.yaml index c873ffa..65f707d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: magic description: "A Laravel-inspired Flutter framework with Eloquent ORM, routing, and MVC architecture." -version: 0.0.9 +version: 0.0.10 homepage: https://magic.fluttersdk.com repository: https://github.com/fluttersdk/magic issue_tracker: https://github.com/fluttersdk/magic/issues diff --git a/skills/magic-framework/SKILL.md b/skills/magic-framework/SKILL.md index 1b8966d..1981301 100644 --- a/skills/magic-framework/SKILL.md +++ b/skills/magic-framework/SKILL.md @@ -5,7 +5,7 @@ when_to_use: "Use proactively when editing or scaffolding a magic app: Magic.ini version: 0.1.16 --- - + # Magic Framework