Skip to content

[local_auth] Convert iOS/macOS to Swift #9459

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

stuartmorgan-g
Copy link
Contributor

Rewrites the iOS/macOS implementation in Swift. This is an in-place rewrite with minimal changes, to minimize the chances of breakage, and to simplify review. Future refactorings can improve the Swift structure (e.g., fully adopting thread enforcement).

flutter/flutter#119104

Pre-Review Checklist

Footnotes

  1. Regular contributors who have demonstrated familiarity with the repository guidelines only need to comment if the PR is not auto-exempted by repo tooling. 2 3

Copy link
Contributor Author

@stuartmorgan-g stuartmorgan-g left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did this conversion manually (as a way to practice Swift); no auto-conversion or AI tools involved. I deliberately did it in-place, pretty much line by line, to simplify side-by-side review. I've left notes about changes that aren't just direct language conversion below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can mostly be compared side-by-side to FLALocalAuthPlugin_Test.h

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be renamed in a follow-up; I didn't rename it here because there are enough changes that git would probably not show it as a diff, making review harder.

@@ -2,6 +2,7 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import LocalAuthentication
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should always have been here, but transitive includes in Obj-C masked the missing include.

@@ -16,24 +17,24 @@ import XCTest
private let timeout: TimeInterval = 30.0

/// A context factory that returns preset contexts.
final class StubAuthContextFactory: NSObject, FLADAuthContextFactory {
final class StubAuthContextFactory: AuthContextFactory {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed NSObject from all the test implementations since the protocols aren't Obj-C now, so it was just cruft.

var contexts: [FLADAuthContext]
init(contexts: [FLADAuthContext]) {
var contexts: [AuthContext]
init(contexts: [AuthContext]) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the Pigeon-generated type names changed because there's no more prefixing.


/// A flutter plugin for local authentication.
// TODO(stuartmorgan): Remove the @unchecked Sendable, and convert to strict thread enforcement.
// This was added to minimize the changes while converting from Swift to Objective-C.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was unfortunate, but it was the best I could come up with to fix all the warnings without significant restructuring. Without this, the places where we dispatch from a potentially-background-thread-callback back to a main thread call to a method on self triggers warnings about self not being Sendable.

There are ways to fix that with more static methods, passing more state around instead of using self, etc., but they would require refactoring that I didn't want to combine with the language conversion as it would have made side-by-side review nearly impossible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think let's keep @unchecked Sendable for now. I plan to look closer into Swift concurrency later this year

self.handleError(authError, options: options, strings: strings, completion: completion)
} else {
// This should not happen according to docs, but if it ever does the plugin should still
// fire the completion.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is new. In Obj-C, we just assumed the docs weren't lying about the error being set if it returned false, but that's not type safe in Swift, and since docs have lied about nullability before I wanted a fallback instead of a crash from force-unwrapping.

animated: true,
completion: nil)
} else {
// TODO(stuartmorgan): Create a new error code for failure to show UI, and return it here.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is new. We were failing to handle the case where there is no view before, so I added minimal handling for now.

self.handleResult(succeeded: false, completion: completion)
}
} else {
alert.runModal()
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is new. We were failing to handle the case where there is no view before.

We could fail like on iOS (see below), but since macOS has a simple way to handle showing an alert without a view I went with that option.

} else {
// The Obj-C declaration of evaluatePolicy defines the callback type as NSError*, but the
// Swift version is (any Error)?, so provide a fallback in case somehow the type is not
// NSError.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch is new, for the reason described here.

@stuartmorgan-g

This comment was marked as resolved.

@stuartmorgan-g stuartmorgan-g added the triage-ios Should be looked at in iOS triage label Jun 23, 2025
var evaluateError: NSError?

// Overridden as read-write to allow stubbing.
var biometryType: LABiometryType = .none
var localizedFallbackTitle: String?

func canEvaluatePolicy(_ policy: LAPolicy) throws {
func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious if this also conforms to the protocol:

func canEvaluatePolicy(_ policy: LAPolicy, error: inout NSError?) -> Bool

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal is to match LAContext, and what I have here is the signature according to Apple:

https://developer.apple.com/documentation/localauthentication/lacontext/canevaluatepolicy(_:error:)

XCTAssertNil(error)
switch resultDetails {
case .success(let successDetails):
XCTAssertEqual(successDetails.result, AuthResult.success)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: XCTAssertEqual(successDetails.result, .success)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done throughout.

XCTAssertEqual(result!.count, 1)
XCTAssertEqual(result![0].value, FLADAuthBiometric.face)
XCTAssertNil(error)
do {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here and other tests - we can mark the function signature as throws, and remove this do/catch block

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done throughout.

return AuthStrings(
reason: "a reason", lockOut: "locked out", goToSettingsButton: "Go To Settings",
goToSettingsDescription: "Settings", cancelButton: "Cancel",
localizedFallbackTitle: localizedFallbackTitle)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: separate lines for each items

(https://google.github.io/swift/ -> Line-Wrapping -> "Comma-delimited lists are only laid out in one direction: horizontally or vertically. ")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I forgot swift-format annoyingly doesn't handle this.

#endif

/// A default context factory that wraps standard LAContext allocation.
struct DefaultAuthContextFactory: AuthContextFactory {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use class unless we need value semantics of struct (since struct comes with overhead)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the overhead? I thought it was the opposite; https://developer.apple.com/documentation/swift/choosing-between-structures-and-classes#Overview says to prefer structs by default.

Copy link
Contributor

@hellohuanlin hellohuanlin Jun 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the overhead?

Value types increase both build time and binary size. Though Apple has improved it over the years, so it shouldn't be our key consideration.

The key consideration is whether we want value or reference semantics (which should be the "control identity" in the link you provided)

For example, factories in general should be reference types, because they often share state, so we don't want multiple factories. This particular factory is stateless, so not the end of the world, but still, semantically we don't want multiple factories when passing it around.

The next example is more interesting:

struct DefaultAlertController {
    var controller: UIAlertController
}

If we use value type like here, imagine this code:

let c1 = DefaultAlertController(...)
let c2 = c1

Then we have 2 separate values (c1 and c2), but both pointing to the same underlying UIAlertController, which is conceptually misleading (it looks like you have created 2 distinct controllers, but you haven't).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! Changed all of them.

) {
if success {
handleResult(succeeded: true, completion: completion)
} else {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: return in success to reduce indentation?

if success {
  handle...
  return
}
...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

LAError.biometryLockout,
LAError.userFallback,
LAError.passcodeNotSet,
LAError.authenticationFailed:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can infer these (e.g. case .biometryNotAvailable)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

completion(
.success(
AuthResultDetails(
result: succeeded ? AuthResult.success : AuthResult.failure,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: infer type

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and below

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

for sheetWindow: NSWindow,
completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
)
@MainActor
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can mark the whole protocol @MainActor so no need each function. (same below)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is problematic until we annotate both Pigeon-generated methods and the FlutterPlugin registration methods as @MainActor, because it cascades through other code that ends up in places like the creation of the default implementations in register(with:), which aren't annotated @MainActor even though in practice they are.

It's much easier for now to annotate the specific methods.

/// instances in unit tests.
protocol AuthAlertController {
@MainActor
func addAction(_ action: UIAlertAction)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add a blank line between each members (same below)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

Copy link
Contributor Author

@stuartmorgan-g stuartmorgan-g left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the Swift tips!

var evaluateError: NSError?

// Overridden as read-write to allow stubbing.
var biometryType: LABiometryType = .none
var localizedFallbackTitle: String?

func canEvaluatePolicy(_ policy: LAPolicy) throws {
func canEvaluatePolicy(_ policy: LAPolicy, error: NSErrorPointer) -> Bool {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal is to match LAContext, and what I have here is the signature according to Apple:

https://developer.apple.com/documentation/localauthentication/lacontext/canevaluatepolicy(_:error:)

XCTAssertNil(error)
switch resultDetails {
case .success(let successDetails):
XCTAssertEqual(successDetails.result, AuthResult.success)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done throughout.

XCTAssertEqual(result!.count, 1)
XCTAssertEqual(result![0].value, FLADAuthBiometric.face)
XCTAssertNil(error)
do {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done throughout.

return AuthStrings(
reason: "a reason", lockOut: "locked out", goToSettingsButton: "Go To Settings",
goToSettingsDescription: "Settings", cancelButton: "Cancel",
localizedFallbackTitle: localizedFallbackTitle)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I forgot swift-format annoyingly doesn't handle this.

#endif

/// A default context factory that wraps standard LAContext allocation.
struct DefaultAuthContextFactory: AuthContextFactory {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense! Changed all of them.

completion(
.success(
AuthResultDetails(
result: succeeded ? AuthResult.success : AuthResult.failure,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

for sheetWindow: NSWindow,
completionHandler handler: ((NSApplication.ModalResponse) -> Void)?
)
@MainActor
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is problematic until we annotate both Pigeon-generated methods and the FlutterPlugin registration methods as @MainActor, because it cascades through other code that ends up in places like the creation of the default implementations in register(with:), which aren't annotated @MainActor even though in practice they are.

It's much easier for now to annotate the specific methods.

/// instances in unit tests.
protocol AuthAlertController {
@MainActor
func addAction(_ action: UIAlertAction)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done


#if os(iOS)
/// A default alert controller that wraps UIAlertController.
struct DefaultAlertController: AuthAlertController {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

/// A default alert controller that wraps UIAlertController.
struct DefaultAlertController: AuthAlertController {
/// The wrapped alert controller.
var controller: UIAlertController
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants