From 503c95da373febc3afef989b11eb175bfe360987 Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Fri, 29 May 2026 12:33:03 +0530 Subject: [PATCH] feat: Allow to configure and open app/link directly from the notifications, menubar and notification cards --- airsync-mac/Core/AppState.swift | 57 +++ .../Core/Util/MacAppLaunchManager.swift | 69 +++ .../Core/Util/MacInstalledAppsScanner.swift | 85 ++++ .../Core/Util/NotificationDelegate.swift | 10 + .../Model/MacAppLaunchPreference.swift | 79 ++++ .../Model/NotificationLaunchDefaults.swift | 112 +++++ .../HomeScreen/AppsView/AppGridView.swift | 26 ++ .../NotificationCardView.swift | 11 + .../NotificationView/NotificationView.swift | 26 +- .../MenuBarNotificationsListView.swift | 4 + .../AppNotificationSettingsView.swift | 323 ++++++++++++- .../Screens/Settings/SyncSettingsView.swift | 426 +++++++++++------- 12 files changed, 1047 insertions(+), 181 deletions(-) create mode 100644 airsync-mac/Core/Util/MacAppLaunchManager.swift create mode 100644 airsync-mac/Core/Util/MacInstalledAppsScanner.swift create mode 100644 airsync-mac/Model/MacAppLaunchPreference.swift create mode 100644 airsync-mac/Model/NotificationLaunchDefaults.swift diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index abc9107f..b0cbc4df 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -93,6 +93,7 @@ class AppState: ObservableObject { self.ringForCalls = UserDefaults.standard.object(forKey: "ringForCalls") == nil ? true : UserDefaults.standard.bool(forKey: "ringForCalls") self.sendNowPlayingStatus = UserDefaults.standard.object(forKey: "sendNowPlayingStatus") == nil ? true : UserDefaults.standard.bool(forKey: "sendNowPlayingStatus") self.autoOpenLinks = UserDefaults.standard.bool(forKey: "autoOpenLinks") + self.openAppOnNotificationClick = UserDefaults.standard.bool(forKey: "openAppOnNotificationClick") var bRate = UserDefaults.standard.integer(forKey: "scrcpyBitrate") if bRate == 0 { bRate = 4 } @@ -171,6 +172,7 @@ class AppState: ObservableObject { loadAppsFromDisk() loadPinnedApps() + loadNotificationLaunchPreferences() // Ensure dock icon visibility is applied on launch updateDockIconVisibility() @@ -287,6 +289,9 @@ class AppState: ObservableObject { @Published var myDevice: Device? = nil @Published var port: UInt16 = Defaults.serverPort @Published var androidApps: [String: AndroidApp] = [:] + @Published var notificationLaunchPreferences: [String: MacAppLaunchPreference] = [:] + /// Set to trigger the "configure notification click action" sheet for a specific package + @Published var configuringLaunchPreferenceFor: String? = nil @Published var pinnedApps: [PinnedApp] = [] { didSet { @@ -630,6 +635,12 @@ class AppState: ObservableObject { } } + @Published var openAppOnNotificationClick: Bool { + didSet { + UserDefaults.standard.set(openAppOnNotificationClick, forKey: "openAppOnNotificationClick") + } + } + @Published var autoAcceptQuickShare: Bool { didSet { UserDefaults.standard.set(autoAcceptQuickShare, forKey: "autoAcceptQuickShare") @@ -1450,6 +1461,52 @@ class AppState: ObservableObject { } } + // MARK: - Notification Launch Preferences + + func saveNotificationLaunchPreferences() { + if let data = try? JSONEncoder().encode(notificationLaunchPreferences) { + UserDefaults.standard.set(data, forKey: "notificationLaunchPreferences") + } + } + + func loadNotificationLaunchPreferences() { + guard let data = UserDefaults.standard.data(forKey: "notificationLaunchPreferences"), + let prefs = try? JSONDecoder().decode([String: MacAppLaunchPreference].self, from: data) else { return } + self.notificationLaunchPreferences = prefs + } + + func setNotificationLaunchPreference(_ pref: MacAppLaunchPreference) { + notificationLaunchPreferences[pref.androidPackage] = pref + saveNotificationLaunchPreferences() + } + + func removeNotificationLaunchPreference(for package: String) { + notificationLaunchPreferences.removeValue(forKey: package) + saveNotificationLaunchPreferences() + } + + func handleNotificationTap(_ notif: Notification) { + // Try opening configured Mac app or web fallback first + let openedOnMac = MacAppLaunchManager.open(package: notif.package) + + // If not opened on Mac, fall back to scrcpy mirroring if available + if !openedOnMac { + if self.device != nil && self.adbConnected && + notif.package != "" && + notif.package != "com.sameerasw.airsync" && + self.mirroringPlus { + ADBConnector.startScrcpy( + ip: self.device?.ipAddress ?? "", + port: self.adbPort, + deviceName: self.device?.name ?? "My Phone", + package: notif.package + ) + } + } + } + + // MARK: - App Storage + func loadAppsFromDisk() { let url = appIconsDirectory().appendingPathComponent("apps.json") do { diff --git a/airsync-mac/Core/Util/MacAppLaunchManager.swift b/airsync-mac/Core/Util/MacAppLaunchManager.swift new file mode 100644 index 00000000..0c984eb0 --- /dev/null +++ b/airsync-mac/Core/Util/MacAppLaunchManager.swift @@ -0,0 +1,69 @@ +// +// MacAppLaunchManager.swift +// airsync-mac +// + +import AppKit +import Foundation + +/// Responsible for launching the user-configured Mac app or web URL +/// when a mirrored Android notification is clicked. +struct MacAppLaunchManager { + + /// Open the configured target for the given Android package name. + /// - Returns: `true` if a preference was found and the launch was attempted. + @discardableResult + static func open(package: String) -> Bool { + // 1. Check user-saved preference (overrides defaults) + if let pref = AppState.shared.notificationLaunchPreferences[package] { + switch pref.target { + case .disabled: + return false // user explicitly disabled + case .macApp, .webURL: + break // handled below + } + return launch(target: pref.target, package: package) + } + + // 2. Fall back to pre-configured default (resolved at runtime) + if let entry = NotificationLaunchDefaults.findDefault(for: package) { + let target = NotificationLaunchDefaults.resolveTarget(for: entry) + return launch(target: target, package: package) + } + + return false + } + + private static func launch(target: MacAppLaunchPreference.LaunchTarget, package: String) -> Bool { + switch target { + case .macApp(let bundleID, _): + guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { + print("[MacAppLaunchManager] App '\(bundleID)' not found — may have been uninstalled.") + return false + } + NSWorkspace.shared.openApplication(at: appURL, configuration: .init()) { _, error in + if let error = error { + print("[MacAppLaunchManager] Failed to open '\(bundleID)': \(error)") + } + } + return true + + case .webURL(var urlString): + // Auto-prepend https:// if no scheme present + if !urlString.lowercased().hasPrefix("http://") && !urlString.lowercased().hasPrefix("https://") { + urlString = "https://\(urlString)" + } + guard let url = URL(string: urlString), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" else { + print("[MacAppLaunchManager] Invalid URL '\(urlString)' for package '\(package)'.") + return false + } + NSWorkspace.shared.open(url) + return true + + case .disabled: + return false + } + } +} diff --git a/airsync-mac/Core/Util/MacInstalledAppsScanner.swift b/airsync-mac/Core/Util/MacInstalledAppsScanner.swift new file mode 100644 index 00000000..1b3750ba --- /dev/null +++ b/airsync-mac/Core/Util/MacInstalledAppsScanner.swift @@ -0,0 +1,85 @@ +// +// MacInstalledAppsScanner.swift +// airsync-mac +// + +import AppKit +import Foundation + +/// Represents a Mac app installed on this machine. +struct InstalledMacApp: Identifiable, Hashable { + let id: String // bundleIdentifier (unique) + let bundleID: String + let name: String // Display name + let icon: NSImage? + + func hash(into hasher: inout Hasher) { hasher.combine(id) } + static func == (lhs: InstalledMacApp, rhs: InstalledMacApp) -> Bool { lhs.id == rhs.id } +} + +/// Scans installed Mac applications from well-known directories. +/// Results are cached for the app session. Call `invalidateCache()` to force a re-scan. +actor MacInstalledAppsScanner { + static let shared = MacInstalledAppsScanner() + + private var cache: [InstalledMacApp]? = nil + + private static let searchDirectories: [String] = [ + "/Applications", + "\(NSHomeDirectory())/Applications", + "/System/Applications", + "/System/Applications/Utilities" + ] + + /// Returns all installed apps, using a cached result if available. + func getInstalledApps() async -> [InstalledMacApp] { + if let cached = cache { return cached } + let apps = await Task.detached(priority: .userInitiated) { + MacInstalledAppsScanner.scanApps() + }.value + self.cache = apps + return apps + } + + func invalidateCache() { + cache = nil + } + + private static func scanApps() -> [InstalledMacApp] { + let fm = FileManager.default + var seen = Set() + var results: [InstalledMacApp] = [] + + for dir in searchDirectories { + let dirURL = URL(fileURLWithPath: dir) + guard let contents = try? fm.contentsOfDirectory( + at: dirURL, + includingPropertiesForKeys: [.isApplicationKey], + options: .skipsHiddenFiles + ) else { continue } + + for url in contents where url.pathExtension == "app" { + guard let bundle = Bundle(url: url), + let bundleID = bundle.bundleIdentifier, + !seen.contains(bundleID) else { continue } + + seen.insert(bundleID) + + let name = bundle.localizedInfoDictionary?["CFBundleName"] as? String + ?? bundle.infoDictionary?["CFBundleName"] as? String + ?? url.deletingPathExtension().lastPathComponent + + let icon = NSWorkspace.shared.icon(forFile: url.path) + + results.append(InstalledMacApp( + id: bundleID, + bundleID: bundleID, + name: name, + icon: icon + )) + } + } + + return results.sorted { $0.name.lowercased() < $1.name.lowercased() } + } +} diff --git a/airsync-mac/Core/Util/NotificationDelegate.swift b/airsync-mac/Core/Util/NotificationDelegate.swift index f26ab625..b18545d7 100644 --- a/airsync-mac/Core/Util/NotificationDelegate.swift +++ b/airsync-mac/Core/Util/NotificationDelegate.swift @@ -70,6 +70,16 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { print("[notification-delegate] Missing device details or package for scrcpy.") } } + // Handle body tap (user clicked the notification itself, not an action button) + else if response.actionIdentifier == UNNotificationDefaultActionIdentifier { + if AppState.shared.openAppOnNotificationClick, + let package = userInfo["package"] as? String { + let opened = MacAppLaunchManager.open(package: package) + if !opened { + print("[notification-delegate] No launch preference configured for package: \(package)") + } + } + } // Handle custom actions else if response.actionIdentifier.hasPrefix("ACT_") { let actionName = String(response.actionIdentifier.dropFirst(4)) diff --git a/airsync-mac/Model/MacAppLaunchPreference.swift b/airsync-mac/Model/MacAppLaunchPreference.swift new file mode 100644 index 00000000..00a1ed2d --- /dev/null +++ b/airsync-mac/Model/MacAppLaunchPreference.swift @@ -0,0 +1,79 @@ +// +// MacAppLaunchPreference.swift +// airsync-mac +// + +import Foundation + +/// Represents the user's configured launch target for a specific Android app's notifications. +struct MacAppLaunchPreference: Codable, Identifiable { + + enum LaunchTarget: Codable { + case macApp(bundleID: String, appName: String) + case webURL(urlString: String) + case disabled + + private enum CodingKeys: String, CodingKey { + case type, bundleID, appName, urlString + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .macApp(let bundleID, let appName): + try container.encode("macApp", forKey: .type) + try container.encode(bundleID, forKey: .bundleID) + try container.encode(appName, forKey: .appName) + case .webURL(let urlString): + try container.encode("webURL", forKey: .type) + try container.encode(urlString, forKey: .urlString) + case .disabled: + try container.encode("disabled", forKey: .type) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type_ = try container.decode(String.self, forKey: .type) + switch type_ { + case "macApp": + let bundleID = try container.decode(String.self, forKey: .bundleID) + let appName = try container.decode(String.self, forKey: .appName) + self = .macApp(bundleID: bundleID, appName: appName) + case "webURL": + let urlString = try container.decode(String.self, forKey: .urlString) + self = .webURL(urlString: urlString) + case "disabled": + self = .disabled + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown LaunchTarget type: \(type_)") + } + } + } + + /// The Android package name (e.g. "com.whatsapp") + let androidPackage: String + /// Display name of the Android app (stored for UI convenience) + let androidAppName: String + /// What to open on macOS when a notification from this app is clicked + var target: LaunchTarget + + var id: String { androidPackage } + + /// Human-readable description of the configured target + var targetDisplayName: String { + switch target { + case .macApp(_, let appName): return appName + case .webURL(let urlString): return urlString + case .disabled: return "Disabled" + } + } + + var targetSystemImage: String { + switch target { + case .macApp: return "desktopcomputer" + case .webURL: return "globe" + case .disabled: return "slash.circle" + } + } +} diff --git a/airsync-mac/Model/NotificationLaunchDefaults.swift b/airsync-mac/Model/NotificationLaunchDefaults.swift new file mode 100644 index 00000000..619e7495 --- /dev/null +++ b/airsync-mac/Model/NotificationLaunchDefaults.swift @@ -0,0 +1,112 @@ +// +// NotificationLaunchDefaults.swift +// airsync-mac +// + +import Foundation +import AppKit + +struct NotificationAppDefault { + let androidPackage: String + let androidAppName: String + let macBundleID: String? // nil = no Mac app exists + let webFallbackURL: String + var packagePattern: String? = nil +} + +struct NotificationLaunchDefaults { + /// All pre-configured apps. Ordered by display importance. + static let all: [NotificationAppDefault] = [ + .init(androidPackage: "com.whatsapp", androidAppName: "WhatsApp", + macBundleID: "net.whatsapp.WhatsApp", webFallbackURL: "https://web.whatsapp.com"), + .init(androidPackage: "com.whatsapp.w4b", androidAppName: "WhatsApp Business", + macBundleID: "net.whatsapp.WhatsApp", webFallbackURL: "https://web.whatsapp.com"), + .init(androidPackage: "org.telegram.messenger", androidAppName: "Telegram", + macBundleID: "ru.keepcoder.Telegram", webFallbackURL: "https://web.telegram.org"), + .init(androidPackage: "com.instagram.android", androidAppName: "Instagram", + macBundleID: nil, webFallbackURL: "https://www.instagram.com"), + .init(androidPackage: "com.twitter.android", androidAppName: "X (Twitter)", + macBundleID: "com.twitter.twitter-mac", webFallbackURL: "https://x.com"), + .init(androidPackage: "com.google.android.gm", androidAppName: "Gmail", + macBundleID: nil, webFallbackURL: "https://mail.google.com"), + .init(androidPackage: "com.Slack", androidAppName: "Slack", + macBundleID: "com.tinyspeck.slackmacgap", webFallbackURL: "https://app.slack.com"), + .init(androidPackage: "com.discord", androidAppName: "Discord", + macBundleID: "com.hammerandchisel.discord", webFallbackURL: "https://discord.com"), + .init(androidPackage: "com.facebook.orca", androidAppName: "Messenger", + macBundleID: "com.facebook.Messenger", webFallbackURL: "https://www.messenger.com"), + .init(androidPackage: "com.spotify.music", androidAppName: "Spotify", + macBundleID: "com.spotify.client", webFallbackURL: "https://open.spotify.com"), + .init(androidPackage: "com.microsoft.office.outlook", androidAppName: "Outlook", + macBundleID: "com.microsoft.Outlook", webFallbackURL: "https://outlook.live.com"), + .init(androidPackage: "notion.id", androidAppName: "Notion", + macBundleID: "notion.id", webFallbackURL: "https://notion.so"), + .init(androidPackage: "com.netflix.mediaclient", androidAppName: "Netflix", + macBundleID: nil, webFallbackURL: "https://www.netflix.com"), + .init(androidPackage: "com.amazon.mShop.android.shopping", androidAppName: "Amazon", + macBundleID: nil, webFallbackURL: "https://www.amazon.in", + packagePattern: "^.*amazon\\.mShop\\.android\\.shopping$"), + .init(androidPackage: "com.amazon.avod.thirdpartyclient", androidAppName: "Prime Video", + macBundleID: "com.amazon.aiv.us", webFallbackURL: "https://www.primevideo.com"), + .init(androidPackage: "com.flipkart.android", androidAppName: "Flipkart", + macBundleID: nil, webFallbackURL: "https://www.flipkart.com"), + .init(androidPackage: "in.startv.hotstar", androidAppName: "JioHotstar", + macBundleID: nil, webFallbackURL: "https://www.hotstar.com"), + .init(androidPackage: "com.linkedin.android", androidAppName: "LinkedIn", + macBundleID: nil, webFallbackURL: "https://www.linkedin.com"), + .init(androidPackage: "com.google.android.youtube", androidAppName: "YouTube", + macBundleID: nil, webFallbackURL: "https://www.youtube.com"), + .init(androidPackage: "com.google.android.apps.youtube.music", androidAppName: "YouTube Music", + macBundleID: nil, webFallbackURL: "https://music.youtube.com"), + .init(androidPackage: "com.goibibo", androidAppName: "Goibibo", + macBundleID: nil, webFallbackURL: "https://www.goibibo.com"), + .init(androidPackage: "com.makemytrip", androidAppName: "MakeMyTrip", + macBundleID: nil, webFallbackURL: "https://www.makemytrip.com"), + .init(androidPackage: "net.blip.android", androidAppName: "Blip", + macBundleID: "net.blip.macos", webFallbackURL: "https://blip.net"), + .init(androidPackage: "com.google.android.apps.classroom", androidAppName: "Google Classroom", + macBundleID: nil, webFallbackURL: "https://classroom.google.com"), + .init(androidPackage: "com.anthropic.claude", androidAppName: "Claude", + macBundleID: "com.anthropic.claudefordesktop", webFallbackURL: "https://claude.ai"), + .init(androidPackage: "com.openai.chatgpt", androidAppName: "ChatGPT", + macBundleID: "com.openai.chat", webFallbackURL: "https://chatgpt.com"), + .init(androidPackage: "com.google.android.apps.bard", androidAppName: "Gemini", + macBundleID: "com.google.gemini", webFallbackURL: "https://gemini.google.com"), + .init(androidPackage: "com.reddit.frontpage", androidAppName: "Reddit", + macBundleID: nil, webFallbackURL: "https://www.reddit.com"), + ] + + static let byPackage: [String: NotificationAppDefault] = + Dictionary(uniqueKeysWithValues: all.map { ($0.androidPackage, $0) }) + + static func findDefault(for package: String) -> NotificationAppDefault? { + if let exact = byPackage[package] { + return exact + } + for entry in all { + if let pattern = entry.packagePattern, + let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { + let range = NSRange(location: 0, length: package.utf16.count) + if regex.firstMatch(in: package, options: [], range: range) != nil { + return entry + } + } + } + return nil + } + + /// Resolve at runtime: check if Mac app is installed, else use web fallback. + static func resolveTarget(for entry: NotificationAppDefault) -> MacAppLaunchPreference.LaunchTarget { + if let bundleID = entry.macBundleID, + NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) != nil { + return .macApp(bundleID: bundleID, appName: entry.androidAppName) + } + return .webURL(urlString: entry.webFallbackURL) + } + + /// Build a synthetic AndroidApp for sheet presentation when real app hasn't appeared yet. + static func syntheticAndroidApp(for entry: NotificationAppDefault) -> AndroidApp { + AndroidApp(packageName: entry.androidPackage, name: entry.androidAppName, + iconUrl: nil, listening: false, systemApp: false) + } +} diff --git a/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift b/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift index 81675dac..318ffe67 100644 --- a/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift +++ b/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift @@ -140,6 +140,16 @@ struct AppGridView: View { } } } + .sheet(item: Binding( + get: { + appState.configuringLaunchPreferenceFor.map { AppConfigureTarget(id: $0) } + }, + set: { appState.configuringLaunchPreferenceFor = $0?.id } + )) { target in + if let app = appState.androidApps[target.id] { + AppNotificationSettingsView(app: app) + } + } } private func launchApp(_ app: AndroidApp) { @@ -161,6 +171,10 @@ struct AppGridView: View { } } +private struct AppConfigureTarget: Identifiable { + let id: String +} + // MARK: - App Grid Item private struct AppGridItemView: View { let app: AndroidApp @@ -306,5 +320,17 @@ private struct AppContextMenuContent: View { systemImage: app.listening ? "bell.slash" : "bell.and.waves.left.and.right" ) } + + Divider() + + Button { + appState.configuringLaunchPreferenceFor = app.packageName + } label: { + let configured = appState.notificationLaunchPreferences[app.packageName] != nil + Label( + configured ? "Edit notification click action" : "Set notification click action", + systemImage: "arrow.up.forward.app" + ) + } } } diff --git a/airsync-mac/Screens/HomeScreen/NotificationView/NotificationCardView.swift b/airsync-mac/Screens/HomeScreen/NotificationView/NotificationCardView.swift index ea573750..ba949e31 100644 --- a/airsync-mac/Screens/HomeScreen/NotificationView/NotificationCardView.swift +++ b/airsync-mac/Screens/HomeScreen/NotificationView/NotificationCardView.swift @@ -145,7 +145,18 @@ struct NotificationCardView: View { Label( "Mute app", systemImage: "bell.slash" ) + } + + Divider() + Button { + AppState.shared.configuringLaunchPreferenceFor = notification.package + } label: { + let configured = AppState.shared.notificationLaunchPreferences[notification.package] != nil + Label( + configured ? "Edit notification click action" : "Set notification click action", + systemImage: "arrow.up.forward.app" + ) } } .listRowSeparator(.hidden) diff --git a/airsync-mac/Screens/HomeScreen/NotificationView/NotificationView.swift b/airsync-mac/Screens/HomeScreen/NotificationView/NotificationView.swift index c9796a26..3cbaf78b 100644 --- a/airsync-mac/Screens/HomeScreen/NotificationView/NotificationView.swift +++ b/airsync-mac/Screens/HomeScreen/NotificationView/NotificationView.swift @@ -70,6 +70,14 @@ struct NotificationView: View { .onChange(of: appState.selectedTab) { _, _ in WhatsNewTourManager.shared.evaluateActiveItem() } + .sheet(item: Binding( + get: { appState.configuringLaunchPreferenceFor.map { NotifConfigureTarget(id: $0) } }, + set: { appState.configuringLaunchPreferenceFor = $0?.id } + )) { target in + if let app = appState.androidApps[target.id] { + AppNotificationSettingsView(app: app) + } + } } else { NotificationEmptyView() } @@ -204,23 +212,13 @@ struct NotificationView: View { private func notificationRowWithTap(for notif: Notification) -> some View { notificationRow(for: notif) .onTapGesture { - handleNotificationTap(notif) + appState.handleNotificationTap(notif) } } +} - private func handleNotificationTap(_ notif: Notification) { - if appState.device != nil && appState.adbConnected && - notif.package != "" && - notif.package != "com.sameerasw.airsync" && - appState.mirroringPlus { - ADBConnector.startScrcpy( - ip: appState.device?.ipAddress ?? "", - port: appState.adbPort, - deviceName: appState.device?.name ?? "My Phone", - package: notif.package - ) - } - } +private struct NotifConfigureTarget: Identifiable { + let id: String } #Preview { diff --git a/airsync-mac/Screens/MenubarView/MenuBarNotificationsListView.swift b/airsync-mac/Screens/MenubarView/MenuBarNotificationsListView.swift index 3a082b92..a17d8c29 100644 --- a/airsync-mac/Screens/MenubarView/MenuBarNotificationsListView.swift +++ b/airsync-mac/Screens/MenubarView/MenuBarNotificationsListView.swift @@ -21,6 +21,10 @@ struct MenuBarNotificationsListView: View { ) .padding(6) .segmentStyle() + .onTapGesture { + appState.handleNotificationTap(notif) + MenuBarManager.shared.hidePopover() + } } if appState.notifications.count > 0 { diff --git a/airsync-mac/Screens/Settings/AppNotificationSettingsView.swift b/airsync-mac/Screens/Settings/AppNotificationSettingsView.swift index ac8a2a38..097f211b 100644 --- a/airsync-mac/Screens/Settings/AppNotificationSettingsView.swift +++ b/airsync-mac/Screens/Settings/AppNotificationSettingsView.swift @@ -11,6 +11,61 @@ struct AppNotificationSettingsView: View { @Environment(\.dismiss) private var dismiss let app: AndroidApp @State private var isSilent = false + + @ObservedObject private var appState = AppState.shared + @State private var clickActionTab: ClickActionTab = .none + @State private var installedApps: [InstalledMacApp] = [] + @State private var isLoadingApps = true + @State private var clickSearchText = "" + @State private var webURLText = "" + @State private var webURLError: String? = nil + + enum ClickActionTab: String, CaseIterable { + case none = "None" + case macApp = "Mac App" + case webURL = "Web URL" + } + + private var existing: MacAppLaunchPreference? { + appState.notificationLaunchPreferences[app.packageName] + } + + private var resolvedTarget: MacAppLaunchPreference.LaunchTarget? { + if let existing = existing { + return existing.target + } + if let entry = NotificationLaunchDefaults.findDefault(for: app.packageName) { + return NotificationLaunchDefaults.resolveTarget(for: entry) + } + return nil + } + + private var selectedBundleID: String? { + if case .macApp(let bundleID, _) = resolvedTarget { + return bundleID + } + return nil + } + + private var filteredMacApps: [InstalledMacApp] { + let apps: [InstalledMacApp] + if clickSearchText.isEmpty { + apps = installedApps + } else { + apps = installedApps.filter { + $0.name.localizedCaseInsensitiveContains(clickSearchText) + } + } + + if let selectedBundleID = selectedBundleID { + return apps.sorted { app1, app2 in + if app1.bundleID == selectedBundleID { return true } + if app2.bundleID == selectedBundleID { return false } + return false + } + } + return apps + } var body: some View { ZStack { @@ -56,6 +111,7 @@ struct AppNotificationSettingsView: View { Divider() VStack(alignment: .leading, spacing: 16) { + // Priority Section HStack { Text(L("settings.notifications.app.priority")) .font(.body) @@ -68,12 +124,83 @@ struct AppNotificationSettingsView: View { .controlSize(.large) } - Spacer() + Divider() + + // Click Action Section + VStack(alignment: .leading, spacing: 12) { + Text("On Notification Click") + .font(.headline) + + Picker("", selection: $clickActionTab) { + ForEach(ClickActionTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + + // Conditionally show tab content + Group { + switch clickActionTab { + case .none: + VStack(alignment: .leading, spacing: 8) { + Text("No custom action configured. Clicking the notification will open the default app or do nothing.") + .font(.callout) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.top, 8) + case .macApp: + macAppTab + case .webURL: + webURLTab + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } .padding(20) + + Divider() + + // Footer + HStack { + if NotificationLaunchDefaults.findDefault(for: app.packageName) != nil, + existing != nil { + Button("Reset to Default") { + appState.removeNotificationLaunchPreference(for: app.packageName) + if let entry = NotificationLaunchDefaults.findDefault(for: app.packageName) { + let defaultTarget = NotificationLaunchDefaults.resolveTarget(for: entry) + switch defaultTarget { + case .macApp: + clickActionTab = .macApp + case .webURL(let url): + clickActionTab = .webURL + webURLText = url + case .disabled: + clickActionTab = .none + } + } else { + clickActionTab = .none + } + } + .buttonStyle(.borderless) + } + + Spacer() + + if clickActionTab == .webURL { + Button("Save URL") { + saveWebURL() + } + .buttonStyle(.borderedProminent) + .disabled(webURLText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) } } - .frame(width: 450, height: 250) + .frame(width: 480, height: 520) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .shadow(radius: 20) .onAppear { @@ -84,5 +211,197 @@ struct AppNotificationSettingsView: View { dict[app.packageName] = newValue UserDefaults.standard.appSilentNotifications = dict } + .onChange(of: clickActionTab) { _, newValue in + if newValue == .none { + if NotificationLaunchDefaults.findDefault(for: app.packageName) != nil { + let pref = MacAppLaunchPreference( + androidPackage: app.packageName, + androidAppName: app.name, + target: .disabled + ) + appState.setNotificationLaunchPreference(pref) + } else { + appState.removeNotificationLaunchPreference(for: app.packageName) + } + } + } + .task { + // Invalidate cache and re-scan when sheet opens + await MacInstalledAppsScanner.shared.invalidateCache() + installedApps = await MacInstalledAppsScanner.shared.getInstalledApps() + isLoadingApps = false + + // Pre-fill web URL if existing preference is a URL + if let target = resolvedTarget { + switch target { + case .macApp: + clickActionTab = .macApp + case .webURL(let url): + clickActionTab = .webURL + webURLText = url + case .disabled: + clickActionTab = .none + } + } else { + clickActionTab = .none + } + } + } + + // MARK: - Mac App tab + private var macAppTab: some View { + VStack(spacing: 0) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search apps…", text: $clickSearchText) + .textFieldStyle(.plain) + + if !clickSearchText.isEmpty { + Button(action: { clickSearchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(8) + .background(Color(nsColor: .windowBackgroundColor)) + .cornerRadius(8) + .padding(.bottom, 6) + + if isLoadingApps { + ProgressView("Scanning installed apps…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if filteredMacApps.isEmpty { + Text("No apps found") + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(filteredMacApps) { macApp in + let isSelected = { + if case .macApp(let bundleID, _) = resolvedTarget { + return bundleID == macApp.bundleID + } + return false + }() + + MacAppRowView(macApp: macApp, isSelected: isSelected) { + let pref = MacAppLaunchPreference( + androidPackage: app.packageName, + androidAppName: app.name, + target: .macApp(bundleID: macApp.bundleID, appName: macApp.name) + ) + appState.setNotificationLaunchPreference(pref) + } + + Divider().padding(.leading, 44) + } + } + } + } + } + } + + // MARK: - Web URL tab + private var webURLTab: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Enter the web URL to open when a \(app.name) notification is clicked.") + .font(.callout) + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 4) { + TextField("https://web.whatsapp.com", text: $webURLText) + .textFieldStyle(.roundedBorder) + .onChange(of: webURLText) { _, _ in webURLError = nil } + + if let error = webURLError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + } + + Text("The URL will open in your system default browser.") + .font(.caption) + .foregroundStyle(.tertiary) + + Spacer() + } + .padding(.top, 8) + } + + private func saveWebURL() { + var raw = webURLText.trimmingCharacters(in: .whitespacesAndNewlines) + if !raw.lowercased().hasPrefix("http://") && !raw.lowercased().hasPrefix("https://") { + raw = "https://\(raw)" + } + guard URL(string: raw) != nil else { + webURLError = "Please enter a valid URL." + return + } + let pref = MacAppLaunchPreference( + androidPackage: app.packageName, + androidAppName: app.name, + target: .webURL(urlString: raw) + ) + appState.setNotificationLaunchPreference(pref) + } +} + +// MARK: - Mac App Row View Helper +private struct MacAppRowView: View { + let macApp: InstalledMacApp + let isSelected: Bool + let onSelect: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 10) { + Group { + if let icon = macApp.icon { + Image(nsImage: icon) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "app") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundStyle(.secondary) + } + } + .frame(width: 26, height: 26) + + Text(macApp.name) + .font(.body) + .foregroundStyle(.primary) + + Spacer() + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(Color.accentColor) + } + } + .padding(.vertical, 7) + .padding(.horizontal, 8) + .contentShape(Rectangle()) + .background( + isSelected + ? Color.accentColor.opacity(0.15) + : (isHovered ? Color.primary.opacity(0.05) : Color.clear) + ) + .cornerRadius(8) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.12)) { + isHovered = hovering + } + } } } + diff --git a/airsync-mac/Screens/Settings/SyncSettingsView.swift b/airsync-mac/Screens/Settings/SyncSettingsView.swift index 1173c0a4..0d56682e 100644 --- a/airsync-mac/Screens/Settings/SyncSettingsView.swift +++ b/airsync-mac/Screens/Settings/SyncSettingsView.swift @@ -17,207 +17,289 @@ struct SyncSettingsView: View { @AppStorage("showInControlCenter") private var showInControlCenter = false @State private var showControlCenterInfo = false + // State for notification permissions + @State private var notificationsGranted = false + @State private var notificationsChecked = false + var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 20) { - // 1. Wireless / Wired ADB - HStack { - headerSection(title: "Connection & ADB", icon: "bolt.horizontal.circle") - Spacer() - GlassButtonView( - label: L("settings.newDevice"), - systemImage: "qrcode", - action: { - showPairingSheet = true - } - ) - .padding(.trailing, 8) - } - VStack(spacing: 12) { - ZStack { + VStack(alignment: .leading, spacing: 20) { + // 1. Connection & ADB HStack { - Label("Auto connect ADB", systemImage: "bolt.horizontal.circle") + headerSection(title: "Connection & ADB", icon: "bolt.horizontal.circle") Spacer() + GlassButtonView( + label: L("settings.newDevice"), + systemImage: "qrcode", + action: { + showPairingSheet = true + } + ) + .padding(.trailing, 8) + } + VStack(spacing: 12) { + ZStack { + HStack { + Label("Auto connect ADB", systemImage: "bolt.horizontal.circle") + Spacer() - if appState.adbConnected { - GlassButtonView( - label: "Disconnect ADB", - systemImage: "stop.circle", - action: { - ADBConnector.disconnectADB() - appState.adbConnected = false + if appState.adbConnected { + GlassButtonView( + label: "Disconnect ADB", + systemImage: "stop.circle", + action: { + ADBConnector.disconnectADB() + appState.adbConnected = false + } + ) + } else { + GlassButtonView( + label: appState.adbConnecting ? "Connecting..." : "Connect ADB", + systemImage: appState.adbConnecting ? "hourglass" : "play.circle", + action: { + if !appState.adbConnecting { + appState.adbConnectionResult = "" // Clear console + appState.manualAdbConnectionPending = true + WebSocketServer.shared.sendRefreshAdbPortsRequest() + appState.adbConnectionResult = "Refreshing latest ADB ports from device..." + } + } + ) + .disabled( + appState.device == nil || appState.adbConnecting || !AppState.shared.isPlus + ) } - ) - } else { - GlassButtonView( - label: appState.adbConnecting ? "Connecting..." : "Connect ADB", - systemImage: appState.adbConnecting ? "hourglass" : "play.circle", - action: { - if !appState.adbConnecting { - appState.adbConnectionResult = "" // Clear console - appState.manualAdbConnectionPending = true - WebSocketServer.shared.sendRefreshAdbPortsRequest() - appState.adbConnectionResult = "Refreshing latest ADB ports from device..." - } + + ZStack { + Toggle( + "", + isOn: $appState.adbEnabled + ) + .labelsHidden() + .toggleStyle(.switch) + .disabled(!AppState.shared.isPlus && AppState.shared.licenseCheck) } - ) - .disabled( - appState.device == nil || appState.adbConnecting || !AppState.shared.isPlus - ) - } + .frame(width: 55) + } - ZStack { - Toggle( - "", - isOn: $appState.adbEnabled - ) - .labelsHidden() - .toggleStyle(.switch) - .disabled(!AppState.shared.isPlus && AppState.shared.licenseCheck) + if !AppState.shared.isPlus && AppState.shared.licenseCheck { + HStack { + Spacer() + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + showingPlusPopover = true + } + .frame(width: 500) + } + } + } + .popover(isPresented: $showingPlusPopover, arrowEdge: .bottom) { + PlusFeaturePopover(message: "Wireless and Wired ADB features are available in AirSync+") + .onTapGesture { + showingPlusPopover = false + } } - .frame(width: 55) - } - if !AppState.shared.isPlus && AppState.shared.licenseCheck { HStack { + Label("Fallback to mDNS services", systemImage: "antenna.radiowaves.left.and.right") Spacer() - Rectangle() - .fill(Color.clear) - .contentShape(Rectangle()) - .onTapGesture { - showingPlusPopover = true + Toggle("", isOn: $appState.fallbackToMdns) + .toggleStyle(.switch) + } + + if let result = appState.adbConnectionResult { + VStack(alignment: .leading, spacing: 6) { + ExpandableLicenseSection(title: "ADB Console", content: "[" + (UserDefaults.standard.lastADBCommand ?? "[]") + "] " + result, copyable: true) + } + } + + HStack { + ZStack { + HStack { + Label(L("settings.wiredAdb"), systemImage: "cable.connector") + Spacer() + Toggle("", isOn: $appState.wiredAdbEnabled) + .toggleStyle(.switch) + .disabled(!AppState.shared.isPlus && AppState.shared.licenseCheck) + } + + if !AppState.shared.isPlus && AppState.shared.licenseCheck { + HStack { + Spacer() + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + showingPlusPopover = true + } + .frame(width: 500) + } } - .frame(width: 500) + } } - } - } - .popover(isPresented: $showingPlusPopover, arrowEdge: .bottom) { - PlusFeaturePopover(message: "Wireless and Wired ADB features are available in AirSync+") - .onTapGesture { - showingPlusPopover = false + + HStack { + Label("Suppress failed messages", systemImage: "bell.slash") + Spacer() + Toggle("", isOn: $appState.suppressAdbFailureAlerts) + .toggleStyle(.switch) } - } + } + .padding() + .glassBoxIfAvailable(radius: 18) - HStack { - Label("Fallback to mDNS services", systemImage: "antenna.radiowaves.left.and.right") - Spacer() - Toggle("", isOn: $appState.fallbackToMdns) - .toggleStyle(.switch) - } + // 2. Clipboard Sync + headerSection(title: "Clipboard Sync", icon: "clipboard") + VStack { + SettingsToggleView(name: "Sync clipboard", icon: "clipboard", isOn: $appState.isClipboardSyncEnabled) - if let result = appState.adbConnectionResult { - VStack(alignment: .leading, spacing: 6) { - ExpandableLicenseSection(title: "ADB Console", content: "[" + (UserDefaults.standard.lastADBCommand ?? "[]") + "] " + result, copyable: true) + HStack { + Label("Auto-open shared links", systemImage: "link") + Spacer() + Toggle("", isOn: $appState.autoOpenLinks) + .toggleStyle(.switch) + .disabled(!appState.isClipboardSyncEnabled) + } + .opacity(appState.isClipboardSyncEnabled ? 1.0 : 0.5) } - } + .padding() + .glassBoxIfAvailable(radius: 18) - HStack { - ZStack { + // 3. Notifications + headerSection(title: "Notifications Sync", icon: "bell.badge") + VStack { + SettingsToggleView(name: "Sync notification dismissals", icon: "bell.badge", isOn: $appState.dismissNotif) + + // Open app on notification click — BETA HStack { - Label(L("settings.wiredAdb"), systemImage: "cable.connector") + Label("Open app on notification click", systemImage: "arrow.up.forward.app") + Text("BETA") + .font(.caption2) + .fontWeight(.semibold) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Color.orange.opacity(0.18)) + .foregroundStyle(.orange) + .clipShape(Capsule()) Spacer() - Toggle("", isOn: $appState.wiredAdbEnabled) + Toggle("", isOn: $appState.openAppOnNotificationClick) .toggleStyle(.switch) - .disabled(!AppState.shared.isPlus && AppState.shared.licenseCheck) } - - if !AppState.shared.isPlus && AppState.shared.licenseCheck { - HStack { - Spacer() - Rectangle() - .fill(Color.clear) - .contentShape(Rectangle()) - .onTapGesture { - showingPlusPopover = true + + + + HStack { + Label("System Notifications", systemImage: "bell.badge") + Spacer() + + if notificationsGranted { + Picker("", selection: $appState.notificationSound) { + Text("Default").tag("default") + ForEach(SystemSounds.availableSounds, id: \.self) { sound in + Text(sound).tag(sound) } - .frame(width: 500) + } + .pickerStyle(MenuPickerStyle()) + .frame(minWidth: 100) + + Button(action: { + SystemSounds.playSound(appState.notificationSound) + }) { + Image(systemName: "play.circle") + } + .buttonStyle(.borderless) + .help("Test notification sound") + } else { + GlassButtonView( + label: "Grant Permission", + systemImage: "bell.badge", + primary: true, + action: { + openNotificationSettings() + } + ) } } - } - } - HStack { - Label("Suppress failed messages", systemImage: "bell.slash") - Spacer() - Toggle("", isOn: $appState.suppressAdbFailureAlerts) - .toggleStyle(.switch) - } - } - .padding() - .glassBoxIfAvailable(radius: 18) - - // 2. Clipboard Sync - headerSection(title: "Clipboard Sync", icon: "clipboard") - VStack { - SettingsToggleView(name: "Sync clipboard", icon: "clipboard", isOn: $appState.isClipboardSyncEnabled) - - HStack { - Label("Auto-open shared links", systemImage: "link") - Spacer() - Toggle("", isOn: $appState.autoOpenLinks) - .toggleStyle(.switch) - .disabled(!appState.isClipboardSyncEnabled) - } - .opacity(appState.isClipboardSyncEnabled ? 1.0 : 0.5) - } - .padding() - .glassBoxIfAvailable(radius: 18) - - // 3. Media Playback - headerSection(title: L("settings.notifications.mediaPlayback"), icon: "play.circle") - VStack { - SettingsToggleView(name: "Send now playing status", icon: "play.circle", isOn: $appState.sendNowPlayingStatus) - - HStack { - Label("Show in Control Center", systemImage: "slider.horizontal.below.rectangle") - Button(action: { showControlCenterInfo = true }) { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) + SettingsToggleView(name: "Send now playing status", icon: "play.circle", isOn: $appState.sendNowPlayingStatus) + + HStack { + Label("Show in Control Center", systemImage: "slider.horizontal.below.rectangle") + Button(action: { showControlCenterInfo = true }) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .alert("Show in Control Center", isPresented: $showControlCenterInfo) { + Button("OK", role: .cancel) {} + } message: { + Text("This feature plays a silent audio track in background in order to show up in macOS media. This may prevent your multi-device bluetooth audio devices to not switch correctly.") + } + Spacer() + Toggle("", isOn: $showInControlCenter) + .toggleStyle(.switch) + .onChange(of: showInControlCenter) { _, enabled in + if enabled { + NowPlayingPublisher.shared.enableSilentAudio() + } else { + NowPlayingPublisher.shared.disableSilentAudio() + } + } + } } - .buttonStyle(.plain) - .alert("Show in Control Center", isPresented: $showControlCenterInfo) { - Button("OK", role: .cancel) {} - } message: { - Text("This feature plays a silent audio track in background in order to show up in macOS media. This may prevent your multi-device bluetooth audio devices to not switch correctly.") + .padding() + .glassBoxIfAvailable(radius: 18) + .onAppear { + checkNotificationPermissions() } - Spacer() - Toggle("", isOn: $showInControlCenter) - .toggleStyle(.switch) - .onChange(of: showInControlCenter) { _, enabled in - if enabled { - NowPlayingPublisher.shared.enableSilentAudio() - } else { - NowPlayingPublisher.shared.disableSilentAudio() + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + checkNotificationPermissions() + } + + // 4. Call Alerts + headerSection(title: "Call Alerts", icon: "phone") + VStack { + HStack { + Label("Call Alert", systemImage: "phone") + Spacer() + + Picker("", selection: $appState.callNotificationMode) { + ForEach(CallNotificationMode.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } } + .pickerStyle(MenuPickerStyle()) + .frame(minWidth: 120) } - } - } - .padding() - .glassBoxIfAvailable(radius: 18) - + SettingsToggleView(name: "Ring for calls", icon: "speaker.wave.3", isOn: $appState.ringForCalls) + } + .padding() + .glassBoxIfAvailable(radius: 18) - // 5. Remote Accessibility Control - headerSection(title: "Remote Accessibility", icon: "accessibility") - VStack { - HStack { - Label("Remote Control Permission", systemImage: "accessibility") - Spacer() - GlassButtonView(label: "Configure", systemImage: "gearshape") { - showRemoteSheet = true + // 5. Remote Accessibility Control + headerSection(title: "Remote Accessibility", icon: "accessibility") + VStack { + HStack { + Label("Remote Control Permission", systemImage: "accessibility") + Spacer() + GlassButtonView(label: "Configure", systemImage: "gearshape") { + showRemoteSheet = true + } + } + } + .padding() + .sheet(isPresented: $showRemoteSheet) { + RemotePermissionView() + } + .sheet(isPresented: $showPairingSheet) { + ADBPairingSheetView() } } + .padding() } - .padding() - .sheet(isPresented: $showRemoteSheet) { - RemotePermissionView() - } - .sheet(isPresented: $showPairingSheet) { - ADBPairingSheetView() - } - } - .padding() - } } @ViewBuilder @@ -233,5 +315,19 @@ struct SyncSettingsView: View { .padding(.horizontal, 8) } + // MARK: - Notification Permission Helpers + func checkNotificationPermissions() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + notificationsGranted = (settings.authorizationStatus == .authorized) + notificationsChecked = true + } + } + } + func openNotificationSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications") { + NSWorkspace.shared.open(url) + } + } }