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
57 changes: 57 additions & 0 deletions airsync-mac/Core/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -171,6 +172,7 @@ class AppState: ObservableObject {

loadAppsFromDisk()
loadPinnedApps()
loadNotificationLaunchPreferences()

// Ensure dock icon visibility is applied on launch
updateDockIconVisibility()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
69 changes: 69 additions & 0 deletions airsync-mac/Core/Util/MacAppLaunchManager.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
85 changes: 85 additions & 0 deletions airsync-mac/Core/Util/MacInstalledAppsScanner.swift
Original file line number Diff line number Diff line change
@@ -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<String>()
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() }
}
}
10 changes: 10 additions & 0 deletions airsync-mac/Core/Util/NotificationDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
79 changes: 79 additions & 0 deletions airsync-mac/Model/MacAppLaunchPreference.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Loading