Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions airsync-mac/Core/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ class AppState: ObservableObject {
self.menubarNotificationStyle = UserDefaults.standard.string(forKey: "menubarNotificationStyle") ?? "both"

self.isClipboardSyncEnabled = UserDefaults.standard.bool(forKey: "isClipboardSyncEnabled")
self.isOtpAutofillEnabled = UserDefaults.standard.object(forKey: "isOtpAutofillEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isOtpAutofillEnabled")
self.autoStartAtLogin = UserDefaults.standard.bool(forKey: "autoStartAtLogin")
self.keepRunningAfterQuit = UserDefaults.standard.bool(forKey: "keepRunningAfterQuit")
self.shortcutScopes = UserDefaults.standard.dictionary(forKey: "shortcutScopes") as? [String: String] ?? [:]
Expand Down Expand Up @@ -388,6 +389,12 @@ class AppState: ObservableObject {
}
}
}
/// Copies detected OTP / 2FA codes from Android notifications to the Mac clipboard.
@Published var isOtpAutofillEnabled: Bool {
didSet {
UserDefaults.standard.set(isOtpAutofillEnabled, forKey: "isOtpAutofillEnabled")
}
}
@Published var shouldRefreshQR: Bool = false
@Published var isConnectionWeak: Bool = false
@Published var webSocketStatus: WebSocketStatus = .stopped
Expand Down Expand Up @@ -1343,6 +1350,10 @@ class AppState: ObservableObject {
}
}
let isAppSilentOnMac = UserDefaults.standard.appSilentNotifications[notif.package] ?? false
if contentChanged && self.isOtpAutofillEnabled,
let code = OtpCodeExtractor.extract(from: notif.title, body: notif.body) {
self.copyVerificationCodeToClipboard(code)
}
if notif.priority != "silent" && !isAppSilentOnMac && contentChanged {
var appIcon: NSImage? = nil
if let iconPath = self.androidApps[notif.package]?.iconUrl {
Expand All @@ -1361,6 +1372,17 @@ class AppState: ObservableObject {
}
}

/// Copies a detected verification code to the Mac clipboard (local only,
/// never synced back to Android) so it can be pasted into the Mac app or site.
private func copyVerificationCodeToClipboard(_ code: String) {
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(code, forType: .string)
self.lastClipboardValue = code
self.lastClipboardChangeCount = pasteboard.changeCount
print("[otp] Verification code copied to clipboard")
}

func postNativeNotification(
id: String,
appName: String,
Expand Down
68 changes: 68 additions & 0 deletions airsync-mac/Core/Util/OTP/OtpCodeExtractor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//
// OtpCodeExtractor.swift
// airsync-mac
//
// Detects one-time verification codes (OTP / 2FA) in notification text so the
// Mac can offer them for paste without the user reaching for the phone.
//

import Foundation

struct OtpCodeExtractor {
/// Keywords indicating the text carries a verification code. This gate keeps
/// plain numbers (years, prices, counts) from triggering a clipboard copy.
private static let keywords = [
"code", "otp", "passcode", "verification", "verify",
"authenticate", "authentication", "2fa", "two-factor", "two factor",
"one-time", "one time", "onetime", "security", "login",
"sign-in", "sign in", "confirm", "activation", "validate"
]

/// Triple-grouped codes like "73 91 08" (separator must be consistent).
private static let grouped3Pattern = #"(?<!\d)(\d{2,4})([ \u00A0\-])(\d{2,4})\2(\d{2,4})(?!\d)"#
/// Grouped codes like "123 456" or "123-456".
private static let grouped2Pattern = #"(?<!\d)(\d{2,4})[ \u00A0\-](\d{2,4})(?!\d)"#
/// Plain codes like "123456".
private static let plainPattern = #"(?<!\d)(\d{4,8})(?!\d)"#

/// Returns the verification code found in the notification, or nil.
/// Longer grouped formats are preferred; the first non-year-like match wins.
static func extract(from title: String, body: String) -> String? {
let text = "\(title)\n\(body)"
let lowered = text.lowercased()
guard keywords.contains(where: { lowered.contains($0) }) else { return nil }

if let triple = matchGroups(pattern: grouped3Pattern, in: text, groups: [1, 3, 4]) {
return triple
}
if let pair = matchGroups(pattern: grouped2Pattern, in: text, groups: [1, 2]) {
return pair
}
return matchGroups(pattern: plainPattern, in: text, groups: [1])
}

private static func matchGroups(pattern: String, in text: String, groups: [Int]) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
for match in regex.matches(in: text, range: range) {
var digits = ""
var complete = true
for group in groups {
let groupRange = match.range(at: group)
guard groupRange.location != NSNotFound,
let swiftRange = Range(groupRange, in: text) else {
complete = false
break
}
digits += text[swiftRange]
}
guard complete, (4...8).contains(digits.count) else { continue }
// Skip year-like 4-digit numbers (e.g. "2026") that slipped past the keyword gate.
if digits.count == 4, let year = Int(digits), (1900...2099).contains(year) {
continue
}
return digits
}
return nil
}
}
2 changes: 2 additions & 0 deletions airsync-mac/Localization/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
"media.pause": "Pause",
"settings.wiredAdb": "Wired ADB support",
"settings.alwaysKillAdbBeforeConnect": "Always kill-server before connect",
"settings.otpAutofill": "Auto-fill verification codes",
"settings.otpAutofill.hint": "Copy OTP codes from Android notifications to the Mac clipboard for quick pasting.",
"quickshare.title": "Quick Share",
"quickshare.button": "Quick Share",
"quickshare.settings.title": "Quick Share Settings",
Expand Down
6 changes: 6 additions & 0 deletions airsync-mac/Screens/Settings/SyncSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,12 @@ struct SyncSettingsView: View {
.disabled(!appState.isClipboardSyncEnabled)
}
.opacity(appState.isClipboardSyncEnabled ? 1.0 : 0.5)

SettingsToggleView(name: L("settings.otpAutofill"), icon: "key.fill", isOn: $appState.isOtpAutofillEnabled)
Text(L("settings.otpAutofill.hint"))
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding()
.glassBoxIfAvailable(radius: 18)
Expand Down