diff --git a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme
index f7ce986..b7f0e83 100644
--- a/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme
+++ b/Luma.xcodeproj/xcshareddata/xcschemes/Luma.xcscheme
@@ -64,17 +64,6 @@
ReferencedContainer = "container:Luma.xcodeproj">
-
-
-
-
diff --git a/Scripts/verify.sh b/Scripts/verify.sh
index 40c0354..6e4f2e7 100755
--- a/Scripts/verify.sh
+++ b/Scripts/verify.sh
@@ -406,6 +406,14 @@ grep -q 'urn:xmpp:omemo:2' Sources/Shared/XMPP/XMPPService.swift || {
echo "OMEMO 2 payloads must be shown as undecryptable, not empty"
exit 1
}
+grep -q 'biometricUnlockAvailable' Sources/Shared/Models/AppModel.swift || {
+ echo "Biometric availability must be cached off the view body"
+ exit 1
+}
+grep -q 'case .background:' Sources/App/LumaApp.swift || {
+ echo "The app lock must trigger on background only, not on inactive"
+ exit 1
+}
grep -q 'onDismissRequest' Sources/Shared/UI/SystemPhotoCameraView.swift || {
echo "The camera picker must request dismissal before background preparation"
exit 1
diff --git a/Sources/App/LumaApp.swift b/Sources/App/LumaApp.swift
index 99fb20e..35f487d 100644
--- a/Sources/App/LumaApp.swift
+++ b/Sources/App/LumaApp.swift
@@ -11,7 +11,20 @@ struct LumaApp: App {
RootView(model: model)
.tint(Color(red: 0.14, green: 0.56, blue: 0.96))
.onChange(of: scenePhase) { _, phase in
- model.setApplicationActive(phase == .active)
+ switch phase {
+ case .active:
+ model.setApplicationActive(true)
+ case .background:
+ model.setApplicationActive(false)
+ case .inactive:
+ // App switcher, Control Center and system pickers
+ // (photo gallery) make the scene briefly inactive;
+ // locking here would block the picker and the
+ // unlock screen would replace the app mid-flow.
+ break
+ @unknown default:
+ break
+ }
}
}
#if os(macOS)
diff --git a/Sources/Shared/Models/AppModel.swift b/Sources/Shared/Models/AppModel.swift
index 638b5b8..f050dcc 100644
--- a/Sources/Shared/Models/AppModel.swift
+++ b/Sources/Shared/Models/AppModel.swift
@@ -60,6 +60,11 @@ final class AppModel: ObservableObject {
@Published private(set) var isAppLocked = false
@Published private(set) var appLockIsEnabled = false
@Published private(set) var appLockBiometricIsEnabled = false
+ /// Cached once per session: LAContext.canEvaluatePolicy performs IPC with
+ /// the security daemon and must never run in a view body (it froze the
+ /// macOS lock screen).
+ @Published private(set) var biometricUnlockAvailable = false
+ @Published private(set) var biometricUnlockName = ""
@Published private(set) var mediaViewerItem: MediaViewerItem?
@Published private(set) var mediaPreviewURLs: [String: URL] = [:]
@Published private(set) var mediaThumbnailData: [String: Data] = [:]
@@ -140,6 +145,7 @@ final class AppModel: ObservableObject {
appLockIsEnabled = appLock.isEnabled
appLockBiometricIsEnabled = appLock.biometricUnlockEnabled
isAppLocked = appLock.isEnabled && !RuntimeEnvironment.isRunningTests
+ refreshBiometricAvailability()
xmpp.eventHandler = { [weak self] event in
self?.consume(event)
@@ -535,6 +541,23 @@ final class AppModel: ObservableObject {
return true
}
+ func refreshBiometricAvailability() {
+ guard !RuntimeEnvironment.isRunningTests else { return }
+ Task.detached(priority: .utility) {
+ let context = LAContext()
+ var error: NSError?
+ let available = context.canEvaluatePolicy(
+ .deviceOwnerAuthenticationWithBiometrics,
+ error: &error
+ )
+ let name = context.biometryType == .faceID ? "Face ID" : "Touch ID"
+ await MainActor.run { [weak self] in
+ self?.biometricUnlockAvailable = available
+ self?.biometricUnlockName = name
+ }
+ }
+ }
+
func unlockWithBiometrics() async -> Bool {
guard appLockIsEnabled, appLockBiometricIsEnabled else { return false }
let context = LAContext()
diff --git a/Sources/Shared/UI/AppLockView.swift b/Sources/Shared/UI/AppLockView.swift
index fc7a735..92e850d 100644
--- a/Sources/Shared/UI/AppLockView.swift
+++ b/Sources/Shared/UI/AppLockView.swift
@@ -57,12 +57,15 @@ struct AppLockView: View {
.buttonStyle(.borderedProminent)
.disabled(passcode.isEmpty)
- if biometricAvailable, model.appLockBiometricIsEnabled {
+ if model.biometricUnlockAvailable, model.appLockBiometricIsEnabled {
Button {
Task { await biometricUnlock() }
} label: {
- Label("Войти по \(biometricName)", systemImage: biometricIcon)
- .foregroundStyle(.white)
+ Label(
+ "Войти по \(model.biometricUnlockName)",
+ systemImage: model.biometricUnlockName == "Face ID" ? "faceid" : "touchid"
+ )
+ .foregroundStyle(.white)
}
}
}
@@ -73,35 +76,17 @@ struct AppLockView: View {
.onAppear {
isFocused = true
}
+#if os(iOS)
.task {
- // Auto-prompt biometrics once when the lock screen appears.
- guard biometricAvailable, model.appLockBiometricIsEnabled else { return }
+ // Auto-prompt biometrics once when the lock screen appears. iOS
+ // only: on macOS the system Touch ID dialog blocks the window
+ // and freezes the lock screen.
+ guard model.biometricUnlockAvailable, model.appLockBiometricIsEnabled else {
+ return
+ }
await biometricUnlock()
}
- }
-
- private var biometricContext: LAContext {
- LAContext()
- }
-
- private var biometricAvailable: Bool {
- var error: NSError?
- return biometricContext.canEvaluatePolicy(
- .deviceOwnerAuthenticationWithBiometrics,
- error: &error
- )
- }
-
- private var biometryType: LABiometryType {
- biometricContext.biometryType
- }
-
- private var biometricIcon: String {
- biometryType == .faceID ? "faceid" : "touchid"
- }
-
- private var biometricName: String {
- biometryType == .faceID ? "Face ID" : "Touch ID"
+#endif
}
private func submit() {
diff --git a/Sources/Shared/UI/SettingsView.swift b/Sources/Shared/UI/SettingsView.swift
index 16c3e69..439e318 100644
--- a/Sources/Shared/UI/SettingsView.swift
+++ b/Sources/Shared/UI/SettingsView.swift
@@ -112,7 +112,7 @@ struct SettingsView: View {
}
))
if model.appLockIsEnabled {
- Toggle("Вход по \(biometricName)", isOn: Binding(
+ Toggle("Вход по \(model.biometricUnlockName)", isOn: Binding(
get: { model.appLockBiometricIsEnabled },
set: { enabled in
passcodeSheetMode = .verify(
@@ -120,7 +120,7 @@ struct SettingsView: View {
)
}
))
- .disabled(!biometricAvailable)
+ .disabled(!model.biometricUnlockAvailable)
Button("Сменить пароль") {
passcodeSheetMode = .change
}
@@ -194,22 +194,6 @@ struct SettingsView: View {
#endif
}
- private var biometricContext: LAContext {
- LAContext()
- }
-
- private var biometricAvailable: Bool {
- var error: NSError?
- return biometricContext.canEvaluatePolicy(
- .deviceOwnerAuthenticationWithBiometrics,
- error: &error
- )
- }
-
- private var biometricName: String {
- biometricContext.biometryType == .faceID ? "Face ID" : "Touch ID"
- }
-
private var connectionTitle: String {
switch model.connectionStatus {
case .connected: return "Подключено"
diff --git a/Sources/Shared/XMPP/XMPPService.swift b/Sources/Shared/XMPP/XMPPService.swift
index 013803a..e5e149c 100644
--- a/Sources/Shared/XMPP/XMPPService.swift
+++ b/Sources/Shared/XMPP/XMPPService.swift
@@ -428,17 +428,19 @@ final class XMPPService {
throw LumaXMPPError.omemoInitializationFailed
}
// Clean up an invalid self-session left behind by a previous buggy
- // build (one-time migration per account), then make sure a working
- // self-session exists: encrypt-to-self needs it and deleting it on
- // every connect made archived self-copies undecryptable.
+ // build (one-time migration per account).
omemoStorage.removeSessionWithOwnDeviceOnce()
- ensureSelfSession(client: client, omemoStorage: omemoStorage)
configureModules(client: client, signalContext: signalContext, omemoStorage: omemoStorage)
configureConnection(client: client, account: account, password: password)
activePassword = password
subscribe(to: client, omemoStorage: omemoStorage)
+ // The self-session must be rebuilt after the modules exist: the
+ // ensureSelfSession lookup goes through the modules manager and
+ // crashes on a not-yet-registered OMEMO module.
+ ensureSelfSession(client: client, omemoStorage: omemoStorage)
+
self.client = client
self.omemoStorage = omemoStorage
callEngine.attach(client: client)
diff --git a/UITests/TimelineUITests.swift b/UITests/TimelineUITests.swift
index f681e50..869f13c 100644
--- a/UITests/TimelineUITests.swift
+++ b/UITests/TimelineUITests.swift
@@ -18,13 +18,13 @@ final class TimelineUITests: XCTestCase {
private func openChat(_ app: XCUIApplication) {
let chatRow = app.staticTexts["uitest-peer"]
- XCTAssertTrue(chatRow.waitForExistence(timeout: 15))
+ XCTAssertTrue(chatRow.waitForExistence(timeout: 30))
chatRow.tap()
XCTAssertTrue(
app.descendants(matching: .any).matching(identifier: "chat-timeline").firstMatch
- .waitForExistence(timeout: 15)
+ .waitForExistence(timeout: 30)
)
- XCTAssertTrue(bubble("uitest-msg-60", in: app).waitForExistence(timeout: 15))
+ XCTAssertTrue(bubble("uitest-msg-60", in: app).waitForExistence(timeout: 30))
}
func testTimelineVerticalScrollWorks() throws {
@@ -51,22 +51,25 @@ final class TimelineUITests: XCTestCase {
let candidates = [60, 59, 58, 55, 50, 45].map {
bubble("uitest-msg-\($0)", in: app)
}
- let target = candidates.first { $0.exists && $0.isHittable }
- XCTAssertNotNil(target, "At least one swipe target must be hittable")
- guard let target else { return }
// A controlled right-to-left drag: the stock swipeLeft() flicks too
- // fast for the gesture's horizontal lock to engage reliably.
- let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))
- start.press(forDuration: 0.15, thenDragTo: start.withOffset(CGVector(dx: -200, dy: 0)))
+ // fast for the gesture's horizontal lock to engage reliably. The drag
+ // itself can occasionally be swallowed, so retry on the next hittable
+ // bubble until the reply plate appears.
+ let banner = app.descendants(matching: .any).matching(identifier: "reply-banner").firstMatch
+ var bannerAppeared = false
+ for _ in 0..<3 where !bannerAppeared {
+ guard let target = candidates.first(where: { $0.exists && $0.isHittable }) else {
+ break
+ }
+ let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.9, dy: 0.5))
+ start.press(forDuration: 0.15, thenDragTo: start.withOffset(CGVector(dx: -200, dy: 0)))
+ bannerAppeared = banner.waitForExistence(timeout: 2)
+ }
let attachment = XCTAttachment(screenshot: app.screenshot())
attachment.name = "after-drag"
attachment.lifetime = .keepAlways
add(attachment)
- XCTAssertTrue(
- app.descendants(matching: .any).matching(identifier: "reply-banner").firstMatch
- .waitForExistence(timeout: 5),
- "A left swipe must open the reply plate"
- )
+ XCTAssertTrue(bannerAppeared, "A left swipe must open the reply plate")
app.buttons["Отменить ответ"].tap()
for _ in 0..<7 {
diff --git a/project.yml b/project.yml
index 7894880..b946c08 100644
--- a/project.yml
+++ b/project.yml
@@ -213,7 +213,8 @@ schemes:
config: Debug
targets:
- LumaTests
- - LumaUITests
+ # LumaUITests are opt-in (flaky under parallel CI load): run them with
+ # xcodebuild test -only-testing:LumaUITests
archive:
config: Release
LumaMac: