Skip to content

win32 6, Swift Package Manager, and a toolchain refresh (6.0.0-dev.1) - #149

Closed
hpoul wants to merge 18 commits into
mainfrom
claude/trusting-kepler-add7db
Closed

win32 6, Swift Package Manager, and a toolchain refresh (6.0.0-dev.1)#149
hpoul wants to merge 18 commits into
mainfrom
claude/trusting-kepler-add7db

Conversation

@hpoul

@hpoul hpoul commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Makes the plugin installable and buildable again on the current toolchain, and adds Swift Package Manager support. Targets 6.0.0-dev.1.

The motivating case: an app that wants authenticationRequired: false to hold an OAuth refresh token could not adopt this plugin at all. Three separate blockers, each verified with a build rather than an analyze.

1. win32 pin made the plugin unresolvable

Every published 5.x pinned win32 >=2.0.0 <6.0.0, so it could not resolve alongside package_info_plus >=10.1.0, which needs win32 ^6.0.1:

biometric_storage >=5.0.0 depends on win32 >=2.0.0 <6.0.0
package_info_plus >=10.1.0 depends on win32 ^6.0.1
=> version solving failed

Lifting the pin means porting to the win32 6.0 API: TEXT() is gone in favour of String.toPcwstr()/toPwstr(), the Cred* calls return Win32Result<bool> carrying the last-error code instead of a BOOL plus GetLastError(), and the enum constants are top-level extension types. Allocations moved into an Arena.

This was never a Windows-only bug. lib/biometric_storage.dart re-exports the Windows implementation under if (dart.library.io), and Flutter generates dart_plugin_registrant.dart for every platform at once. An iOS release build of a consuming app contains:

import 'package:biometric_storage/biometric_storage.dart' as biometric_storage;
...
} else if (Platform.isWindows) {
  biometric_storage.Win32BiometricStoragePlugin.registerWith();

So a package:win32 break fails an iOS build. A federated biometric_storage_windows split would not change that — the registrant would import that package instead, on every platform. The defence is a compile: test/biometric_storage_test.dart now imports the public barrel rather than src/, so flutter test on any host compiles the win32 bindings, and CI runs the suite on Windows too. Verified by reintroducing a TEXT() call and watching the original error reappear as a test failure.

Also fixed while in there: writing an empty value reached Uint8List.toNative(), which rejects empty lists. And the fileName: key under windows: was a web-only key the tool never read.

2. Swift Package Manager

Flutter 3.44+ defaults to SwiftPM and falls back to CocoaPods for any plugin without a Package.swift — so adding this plugin to a migrated app silently regenerated its Podfiles. The CocoaPods registry also goes read-only on 2 December 2026.

Both are shipped: a Package.swift (with the FlutterFramework dependency the tool now expects) alongside the podspec. The sources moved to darwin/ under sharedDarwinSource: true, which removes the symlink ios/Classes used to reach into macos/Classes and the Objective-C shim — neither survives a SwiftPM target.

Verified by building an app that also depends on package_info_plus ^10.2.1: iOS and macOS both build with no Podfile generated, and the plugin's Swift symbols are in the linked binaries. Then verified again with SwiftPM disabled, so the podspec path still works.

Visible change: the macOS plugin class is BiometricStoragePlugin rather than BiometricStorageMacOSPlugin, and iOS no longer vends BiometricStoragePlugin.h. Flutter generates the registrant for both, so this only matters to someone registering the plugin by hand.

3. Staleness

Last stable was 5.0.1, roughly two years old.

  • Android: AGP 8.1.4 → 8.13.2, Kotlin 2.0.21 → 2.2.20, compileSdk 35 → 36, fragment-ktx 1.9.0, core-ktx 1.18.0, slf4j 2.0.18, kotlin-logging 8. lintOptions was removed in AGP 9, so it became lint. The plugin no longer applies the Kotlin Gradle Plugin itself — AGP 9 warns about that and future Flutter releases reject it.
  • androidx.biometric is held at 1.4.0-alpha05 deliberately. alpha06 added minCompileMinorSdk=1, which forces consumers to compileSdk 36.1. AGP 9.1 ignores that field, AGP 9.3 enforces it — so a bump to alpha07 raises every consumer's floor invisibly. alpha05 is the newest that asks only for compileSdk 35.
  • Example on AGP 9.3.2 / Gradle 9.5.0 / Kotlin 2.4.10 — ahead of the flutter create template on purpose, which is what caught the item above.
  • CI: actions/checkout@v1 → v4, a web job that set up Flutter on Windows and then did nothing → a real Windows test run, plus formatting and --fatal-infos analysis, and an assertion that no Podfile appeared. The example is a separate package, so analyze and test now run there too.
  • Dart sources reformatted with the current formatter (its own commit), logging_appenders 1.1 → 2.0 in the example, and its widget test — which had never passed, asserting a string the UI never contained — replaced with a real smoke test.

Fixes

Supersedes

  • Supersedes Update win32's constants #133 — its win32 constant update and its SwiftPM migration are both covered here. This PR shares the darwin sources through sharedDarwinSource rather than duplicating them under ios/ and macos/.
  • Supersedes Update to latest flutter #100 — "Update to latest flutter" (2023), pubspec constraints only.

Two more are already obsolete, though not because of this PR — worth closing separately:

Documentation corrections

The README presented FlutterFragmentActivity and a Theme.AppCompat theme as unconditional requirements, which is what made adoption look invasive. Both are conditions on showing a prompt:

  • withAuth returns straight to its callback when authenticationRequired is false, so attachedActivity is never read and BiometricPrompt is never constructed.
  • androidx.biometric only draws its own AppCompat dialog when isUsingFingerprintDialog() holds: SDK_INT < 28, API 28 without a fingerprint sensor, or a device on the shouldUseFingerprintForCrypto list. The README said "Android < 29".

Also documented: IosPromptInfo.saveTitle/accessTitle are not rendered on a Face ID device. Checked on an iPhone Xr running iOS 18.7.9 with a build that set the deprecated kSecUseOperationPrompt and its replacement LAContext.localizedReason to different marker strings — a screen recording of a read shows neither. They still reach the system, and Touch ID and macOS do draw them. kSecUseOperationPrompt, deprecated since iOS 14 / macOS 11, is replaced by localizedReason in this PR; the strings were invisible under Face ID before the change too.

Breaking

Requires Dart 3.10 / Flutter 3.44, which is win32 6.0.1's own floor and where SwiftPM became the default. The plugin still declares AGP 8.13.2 rather than 9.x, so it does not drag consumers onto Gradle 9.

Not addressed

Not published to pub.dev.

🤖 Generated with Claude Code

hpoul and others added 18 commits August 25, 2026 12:36
Dart 3.7 changed the default style and nothing here had been run through it
since, so `dart format --set-exit-if-changed` could not be added to CI without
a reformat first. This commit is that reformat and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every published 5.x pinned `win32 >=2.0.0 <6.0.0`, so the plugin could not be
resolved alongside `package_info_plus >=10.1.0`, which needs win32 ^6.0.1.
Lifting the pin means porting to the 6.0 API: `TEXT()` is gone in favour of
`String.toPcwstr()`/`toPwstr()`, the Cred* calls return `Win32Result<bool>`
carrying the last-error code rather than a BOOL plus `GetLastError()`, and the
enum constants are top-level extension types. Allocations move into an `Arena`,
which is what removes the hand-rolled free of every pointer.

This is not a Windows-only fix. `lib/biometric_storage.dart` re-exports the
Windows implementation under `if (dart.library.io)`, and Flutter generates
`dart_plugin_registrant.dart` for every platform at once — an iOS release build
of a consuming app imports the barrel and calls
`Win32BiometricStoragePlugin.registerWith()` behind a `Platform.isWindows`
check. So a win32 API break fails an iOS build. The test now imports the public
barrel instead of `src/`, which makes `flutter test` on any host compile the
bindings; reintroducing a `TEXT()` call reproduces the original error there.

Also: writing an empty value used to reach `Uint8List.toNative()`, which
rejects an empty list. A zero-length blob now gets a valid one-byte pointer.

The `fileName:` key under `windows:` is a web-only key and was never read by
the tool; dropped rather than corrected, since the barrel import is what the
registrant needs.

Requires Dart 3.10 / Flutter 3.44, which is win32 6.0.1's own floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Flutter 3.44 makes SwiftPM the default for iOS and macOS, and falls back to
CocoaPods for any plugin without a Package.swift — so adding this plugin to an
app that had finished the migration silently regenerated its Podfiles. The
CocoaPods registry also goes read-only on 2 December 2026, and Flutter's advice
until then is to ship both, which is what this does: a Package.swift alongside
the podspec, with the FlutterFramework dependency the tool now expects.

SwiftPM wants the sources under `<platform>/<plugin_name>/Sources/<plugin_name>`,
which the old layout could not satisfy twice over: `ios/Classes` reached the
shared implementation through a symlink into `macos/Classes`, and mixing the
Objective-C shim with Swift in one target is not something a SwiftPM target
does. Both go away. The sources now live once in `darwin/`, shared through
`sharedDarwinSource: true`, and the registrar's messenger — a method on iOS, a
property on macOS — is the only thing behind an `#if os(...)`.

Consequences worth knowing about: the macOS plugin class is `BiometricStoragePlugin`
rather than `BiometricStorageMacOSPlugin`, and iOS no longer vends
`BiometricStoragePlugin.h`. Flutter generates the registrant for both, so this
is only visible to someone registering the plugin by hand.

The example's Xcode projects were regenerated from the current template rather
than unpicked: they carried CocoaPods build phases, an iOS 12 deployment target
and a macOS 10.14 one, all below what Flutter now supports. Its own
customisations — bundle ids, signing team, NSFaceIDUsageDescription, the
keychain-access-groups entitlements — are carried over.

Verified by building an app that also depends on package_info_plus ^10.2.1 for
iOS and macOS: both succeed, no Podfile is generated, and the plugin's Swift
symbols are in the linked binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Kotlin 2.0.21 to 2.2.20, AGP 8.1.4 to 8.13.2, compileSdk 35 to 36,
androidx.biometric 1.4.0-alpha02 to alpha07, core-ktx 1.10.1 to 1.18.0,
fragment-ktx 1.6.1 to 1.9.0, slf4j 2.0.7 to 2.0.18, kotlin-logging 5 to 8.
core-ktx stops at 1.18.0 deliberately: 1.19.0 declares minCompileSdk 37, above
what AGP 9.1 supports.

Two of these are not version churn. `lintOptions` was removed in AGP 9, so the
block had to become `lint`. And the plugin no longer applies the Kotlin Gradle
Plugin itself: AGP 9 brings its own Kotlin support and Flutter warns that
plugins applying KGP will stop building, while on AGP 8 Flutter's own Gradle
plugin applies `kotlin-android` for us. Either way it arrives after this file is
evaluated, so `kotlin { }` is not available at the top level — which is also
what made `jvmToolchain` unresolvable in some consumer builds (#107). The JVM
target is configured from inside `pluginManager.withPlugin(...)` instead.

`canAuthenticate()` used to throw on any BiometricManager status code it did not
recognise, and androidx.biometric keeps adding them — Android 16 introduced
BIOMETRIC_ERROR_NOT_ENABLED_FOR_APPS (21) and the call started blowing up
(#148). Unmapped codes now report as ErrorStatusUnknown, which is what that
value is for, and log the code.

The example moves to Gradle 9.3.1 and AGP 9.1.0, which is what `flutter create`
emits today and what Java 25 requires, so the plugin is exercised against the
newest toolchain rather than the one it declares. Its `android.builtInKotlin`
stays false: AGP 9.1.0 ships KGP 2.2.10 and Flutter 3.47 requires 2.2.20.

Also drops two dead files: settings_aar.gradle, from the pre-1.0 aar workflow,
and an empty app/gradle.properties.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README presented `MainActivity extends FlutterFragmentActivity` and a
`Theme.AppCompat` theme as flat requirements of using the plugin at all, which
makes adoption look far more invasive than it is — and is the reason this pass
exists, since a consumer wanting `authenticationRequired: false` needs neither.

Both are conditions on showing a prompt. `withAuth` returns straight to its
callback when `authenticationRequired` is false, so `attachedActivity` is never
read and `BiometricPrompt` is never constructed; a plain `FlutterActivity` costs
one logged error and only authenticated reads and writes.

The theme is narrower still. androidx.biometric only builds its own dialog with
`androidx.appcompat.app.AlertDialog` when `isUsingFingerprintDialog()` holds:
SDK_INT < 28, SDK_INT == 28 without a fingerprint sensor, or a device on the
`shouldUseFingerprintForCrypto` list. The README said "Android < 29"; API 29 and
up use the system BiometricPrompt and the theme does not matter.

Also: the getStorage snippet was missing its `await` and then read from a
variable it never assigned, the kotlin version to "make sure to use" was 1.4.31,
and the iOS/macOS deployment targets were three Flutter releases stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The workflow had drifted: checkout@v1, flutter-action@v1, a Java 12 toolchain,
an ubuntu-22.04 matrix whose only entry excluded itself, and a `web` job that
set up Flutter on Windows and then did nothing at all.

Windows now runs the test suite rather than nothing, which is the platform whose
bindings just broke everyone else's builds. Formatting and `--fatal-infos`
analysis run alongside the tests. The iOS build asserts that no Podfile appeared,
so a regression in the Swift Package Manager support fails rather than passing
quietly with CocoaPods underneath.

macOS stays out of the build matrix for the reason the old comment gave, now
stated precisely: the example's keychain-access-groups entitlement resolves
$(AppIdentifierPrefix) from the signing team, so its Runner cannot be built
without a development certificate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Matches the plugin's own `^3.10.0` and flutter_lints 6. The analyzer excludes
and the .metadata revisions are what `flutter pub get` and `flutter create`
wrote; the web platform entry is put back by hand, since regenerating only ios
and macos dropped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The if/else chain over `Platform.isX` had the unsupported-platform case at the
bottom of a five-branch ladder. Switching on `Platform.operatingSystem` puts
each platform's payload on one line beside its name, and the fallthrough binds
the value it reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mostly the things this pass cost time to work out and that the code does not
say: that the Windows implementation is compiled on iOS and macOS too and why
a federated split would not change that, that `fileName:` under `windows:` was
never read, how the darwin sources are shared, and why the plugin must not
apply the Kotlin Gradle Plugin.

The working-practice half is taken from the conventions in the owner's other
Flutter project, kept to what applies to a plugin: the commands, structured
edits over shell text manipulation, backgrounding slow builds, and the habit
that produced most of the above — an analyze is not a compile, and a plugin
that failed to register still builds green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`isUsingFingerprintDialog()` is `SDK_INT < 28`, not `<= 28`; the API 28 case is
the separate one where the device has no fingerprint sensor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the barrel export, naming the file with dartFileName, and splitting
Windows into a federated package each fail for a different reason, and stating
only the last of them invites the first two to be tried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Carried over from the template podspec, which said 'Your Company' and
'email@example.com'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Xcode writes .swiftpm/xcode/xcuserdata beside Package.swift as soon as anyone
opens the package, and Package.resolved has nothing to pin here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ionPrompt

kSecUseOperationPrompt has been deprecated since iOS 14 / macOS 11, and warned
twice on every build. Its replacement is LAContext.localizedReason, which means
the reason travels on the context the query already carries rather than beside
it in the query dictionary.

Set on every call rather than once, because `context` may hand back a context
reused across calls when darwinTouchIDAuthenticationForceReuseContextDuration
is set. An empty or absent title is left alone: localizedReason is a non-optional
String, where assigning nil to the old query key simply removed it.

localizedReason is API_AVAILABLE(macos(10.13), ios(11.0)), below the package's
declared iOS 13 / macOS 10.15, so it needs no availability guard.

Verified by building an app for iOS and macOS: both succeed with no remaining
deprecation warnings from this file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unanchored rule also matched
example/{ios,macos}/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved,
which is an app-level lockfile and the one case where committing it is right.
Neither file exists today — every dependency in the generated package graph is
a local path dependency, which SwiftPM does not record — but the rule should
not be waiting to hide the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checked on an iPhone Xr running iOS 18.7.9 with a diagnostic build that set the
deprecated kSecUseOperationPrompt and the replacement LAContext.localizedReason
at the same time, to different marker strings. A screen recording of the read
shows neither: the Face ID panel is the glyph and the words "Face ID", and the
alert after a failed scan offers only "Face ID erneut versuchen" and
"Abbrechen".

So the strings were never rendered here, before the migration or after it —
worth recording, because someone customising IosPromptInfo and seeing nothing
change would otherwise reasonably suspect the plugin of dropping them. They
still reach the system, and Touch ID and macOS do draw them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
logging_appenders 1.1 to 2.0, which drops dio in favour of package:http and
takes three transitive dependencies with it. The example only uses PrintAppender
and the formatter types, so nothing in it changes.

The widget test has never passed: it is the stock plugin-template test, asserting
a Text starting with 'Running on:' that the example's UI has never contained —
zero occurrences in main.dart at any commit. Replaced with a smoke test of what
is actually on screen.

The example is a separate package, so `flutter analyze` and `flutter test` at the
repository root never reached it, which is how the test stayed broken. CI now
runs both there too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Upgrading the example from AGP 9.1.0 to 9.3.2 (with Gradle 9.3.1 to 9.5.0 and
KGP 2.4.0 to 2.4.10, both required by it) surfaced a regression this branch had
already introduced without noticing.

androidx.biometric 1.4.0-alpha06 added `minCompileMinorSdk=1` to its AAR
metadata, so alpha06 and alpha07 require consumers to compile against
compileSdk 36.1. AGP 9.1 does not read that field and builds happily; AGP 9.3
enforces it and fails `checkDebugAarMetadata`. Bumping alpha02 to alpha07
therefore moved every consumer's floor from compileSdk 35 to 36.1, invisibly,
for as long as they stayed on AGP 9.1.

alpha05 is the newest release that asks only for compileSdk 35. Nothing in the
plugin needs what alpha06 added: the fix for the unmapped Android 16 status code
falls back on any unrecognised value rather than naming the new constant, so it
does not depend on the constant existing.

Keeping the example ahead of the `flutter create` template is what caught this,
which is the argument for keeping it there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hpoul hpoul closed this Aug 25, 2026
@hpoul
hpoul deleted the claude/trusting-kepler-add7db branch August 25, 2026 11:30
@hpoul

hpoul commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

Closed as a side effect of renaming the branch claude/trusting-kepler-add7dbwin32-6-and-swiftpm; GitHub did not retarget it and it cannot be reopened now that the old head ref is gone.

Superseded by #150, same commits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment