Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
cce76fe
fix: wireless adb with scrcpy with device picker
sameerasw Jun 3, 2026
310bd01
feat: wireless ADB support for native mirroring
sameerasw Jun 3, 2026
099320c
feat: add support for manual BLE device connection and auto-reconnect…
sameerasw Jun 3, 2026
dfd3005
feat: android mirror keyboard support
sameerasw Jun 3, 2026
0cf0063
feat: add setting to swap Command and Control keys for Scrcpy mirroring
sameerasw Jun 3, 2026
f03f74e
feat: show butons toggle for mirror in settings
sameerasw Jun 3, 2026
477c67e
feat: implement native scroll in android mirror
sameerasw Jun 3, 2026
4e79f4d
refactor: optimize JSON decoding and add debug logging and trace file…
sameerasw Jun 4, 2026
dd19dac
refactor: extract playback state to a centralized PlaybackState class…
sameerasw Jun 4, 2026
69774c9
refactor: consolidate media and call state into AppState and remove P…
sameerasw Jun 4, 2026
53cb449
refactor: prevent BLE connections from overriding or auto-connecting …
sameerasw Jun 4, 2026
7fa4fe9
refactor: remove 3D tilt gesture and animation logic from PhoneView
sameerasw Jun 4, 2026
ac11d17
feat: Sidebar mirroring
sameerasw Jun 4, 2026
a42c76e
feat:home button android mirror input shortcuts
sameerasw Jun 4, 2026
731f3b6
fix: synchronize mirroring states, improve Scrcpy process cleanup, an…
sameerasw Jun 4, 2026
053ed35
fix: Sidebar wallpaper with mirror here
sameerasw Jun 4, 2026
c081d6c
feat: expreimental desktop mirror
sameerasw Jun 4, 2026
5ae49a0
refactor: Notification settings category
sameerasw Jun 4, 2026
0bc1bc1
feat: add per-app notification settings section to Notifications view
sameerasw Jun 4, 2026
4bbb688
feat: implement App notification settigns sheet and localize notifica…
sameerasw Jun 4, 2026
e7a6846
feat: add per-app silent notification settings and update mirroring p…
sameerasw Jun 4, 2026
bb2385a
refactor: safely access coordinate array indices and update mirroring…
sameerasw Jun 4, 2026
6c0eb91
feat: replace default toolbar search with custom floating search
sameerasw Jun 4, 2026
f2cadc5
feat: add search-to-launch functionality, visual states, and persiste…
sameerasw Jun 4, 2026
9612888
refactor: remove package name filtering from app search logic in AppG…
sameerasw Jun 4, 2026
36ae3d5
feat: implement prefix-based app sorting and add dynamic keyboard sho…
sameerasw Jun 4, 2026
8af54b5
feat: update and expand keyboard shortcuts for sidebar mirroring acti…
sameerasw Jun 4, 2026
2693476
feat: implement keyboard shortcuts for tab navigation, connection, an…
sameerasw Jun 4, 2026
93a0c07
fix: implement local event monitoring in MenuBarManager to correctly …
sameerasw Jun 4, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,4 @@ AGENTS.md
.tend-stack
docs/plans/
build.log
*.trace
70 changes: 56 additions & 14 deletions airsync-mac/Core/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class AppState: ObservableObject {
private var lastClipboardValue: String? = nil
private var shouldSkipSave = false
private var cancellables = Set<AnyCancellable>()
private var bleWakeUpWorkItem: DispatchWorkItem?
private static let licenseDetailsKey = "licenseDetails"

@Published var isOS26: Bool = true
Expand Down Expand Up @@ -103,6 +104,7 @@ class AppState: ObservableObject {

self.useADBWhenPossible = UserDefaults.standard.object(forKey: "useADBWhenPossible") == nil ? true : UserDefaults.standard.bool(forKey: "useADBWhenPossible")
self.useNativeMirroringByDefault = UserDefaults.standard.bool(forKey: "useNativeMirroringByDefault")
self.useNativeDesktopMirroringByDefault = UserDefaults.standard.bool(forKey: "useNativeDesktopMirroringByDefault")
self.isMusicCardHidden = UserDefaults.standard.bool(forKey: "isMusicCardHidden")

self.isCrashReportingEnabled = UserDefaults.standard.object(forKey: "isCrashReportingEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isCrashReportingEnabled")
Expand Down Expand Up @@ -172,6 +174,7 @@ class AppState: ObservableObject {

// Reset mirroring state on launch to prevent auto-opening if it was open during last session
self.isNativeMirroring = false
self.isNativeDesktopMirroring = false

startMediaTimer()

Expand Down Expand Up @@ -206,23 +209,34 @@ class AppState: ObservableObject {
self.selectedTab = .notifications
}

// BLE scan management: pause when a regular (non-BLE) connection is active
// BLE connection management: Wi-Fi priority over BLE
let isRegularConnection = device?.ipAddress != nil && device?.ipAddress != "BLE"
let wasRegularConnection = oldValue?.ipAddress != nil && oldValue?.ipAddress != "BLE"

if isRegularConnection && !wasRegularConnection {
// Regular connection established — stop BLE scanning to save power/bandwidth
if isBLEEnabled && BLECentralManager.shared.connectionStatus == .scanning {
print("[state] Regular connection active — pausing BLE scan")
if isRegularConnection {
// Regular connection established — immediately put BLE to idle (disconnect & stop scan)
if isBLEEnabled {
print("[state] Regular connection active — disconnecting BLE and putting to idle")
BLECentralManager.shared.stopScanning()
BLECentralManager.shared.disconnect()
}
// Cancel any pending delayed BLE wake-up tasks
self.bleWakeUpWorkItem?.cancel()
self.bleWakeUpWorkItem = nil
} else if !isRegularConnection && wasRegularConnection {
// Regular connection lost — resume BLE scanning if BLE is enabled and not already BLE-connected
if isBLEEnabled && !BLECentralManager.shared.isAuthenticated {
print("[state] Regular connection lost — resuming BLE scan")
BLECentralManager.shared.isManuallyDisconnected = false
BLECentralManager.shared.startScanning()
// Regular connection lost — schedule BLE scanning after 5 seconds to give Wi-Fi a chance to reconnect
self.bleWakeUpWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let self = self else { return }
let stillDisconnected = self.device?.ipAddress == nil || self.device?.ipAddress == "BLE"
if stillDisconnected && self.isBLEEnabled && !BLECentralManager.shared.isAuthenticated {
print("[state] Regular connection stayed lost for 5s — resuming BLE scan")
BLECentralManager.shared.isManuallyDisconnected = false
BLECentralManager.shared.startScanning()
}
}
self.bleWakeUpWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: workItem)
}
}
}
Expand Down Expand Up @@ -307,7 +321,30 @@ class AppState: ObservableObject {
@Published var adbConnectionMode: ADBConnectionMode? = nil

@Published var recentApps: [AndroidApp] = []
@Published var isNativeMirroring: Bool = false
@Published var isNativeMirroring: Bool = false {
didSet {
if isNativeMirroring {
if isSidebarMirroring { isSidebarMirroring = false }
if isNativeDesktopMirroring { isNativeDesktopMirroring = false }
}
}
}
@Published var isSidebarMirroring: Bool = false {
didSet {
if isSidebarMirroring {
if isNativeMirroring { isNativeMirroring = false }
if isNativeDesktopMirroring { isNativeDesktopMirroring = false }
}
}
}
@Published var isNativeDesktopMirroring: Bool = false {
didSet {
if isNativeDesktopMirroring {
if isNativeMirroring { isNativeMirroring = false }
if isSidebarMirroring { isSidebarMirroring = false }
}
}
}
@Published var temporaryDragLabel: String? = nil

// MARK: - Centralized Media Seekbar State
Expand Down Expand Up @@ -644,6 +681,12 @@ class AppState: ObservableObject {
}
}

@Published var useNativeDesktopMirroringByDefault: Bool {
didSet {
UserDefaults.standard.set(useNativeDesktopMirroringByDefault, forKey: "useNativeDesktopMirroringByDefault")
}
}

@Published var isFileAccessEnabled: Bool {
didSet {
if !isPlus && licenseCheck {
Expand Down Expand Up @@ -1092,9 +1135,8 @@ class AppState: ObservableObject {
self.notifications.insert(notif, at: 0)
}
}
// Trigger native macOS notification if not silent and content actually changed/new
// Default to alerting if priority is missing (backwards compatibility)
if notif.priority != "silent" && contentChanged {
let isAppSilentOnMac = UserDefaults.standard.appSilentNotifications[notif.package] ?? false
if notif.priority != "silent" && !isAppSilentOnMac && contentChanged {
var appIcon: NSImage? = nil
if let iconPath = self.androidApps[notif.package]?.iconUrl {
appIcon = NSImage(contentsOfFile: iconPath)
Expand Down
6 changes: 6 additions & 0 deletions airsync-mac/Core/BLE/BLECentralManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ extension BLECentralManager: CBCentralManagerDelegate {

// Auto connect if enabled and not manually disconnected
if AppState.shared.isBLEAutoConnectEnabled && !isManuallyDisconnected {
let isWifiConnected = AppState.shared.device != nil && AppState.shared.device?.ipAddress != "BLE" && AppState.shared.device?.ipAddress != "Bluetooth LE"
if isWifiConnected {
print("[BLE] Regular Wi-Fi connection is active — skipping auto-connect to BLE")
return
}

let token = UserDefaults.standard.string(forKey: "bleAuthToken") ?? ""
if token.isEmpty {
return
Expand Down
15 changes: 14 additions & 1 deletion airsync-mac/Core/MenuBarManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class MenuBarManager: NSObject {
private var statusItem: NSStatusItem?
private var menubarPanel: MenubarPanel?
private var eventMonitor: Any?
private var localEventMonitor: Any?
private var cancellables = Set<AnyCancellable>()
private var appState = AppState.shared
private var temporaryDragLabel: String?
Expand Down Expand Up @@ -187,14 +188,22 @@ class MenuBarManager: NSObject {

appState.isMenubarWindowOpen = true

// Monitor clicks outside to close
// Monitor clicks outside to close (both globally and locally within current app)
eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
if let eventLocation = NSEvent.mouseLocation as NSPoint?,
let panelFrame = self?.menubarPanel?.frame,
!NSMouseInRect(eventLocation, panelFrame, false) {
self?.hidePopover()
}
}
localEventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
if let eventLocation = NSEvent.mouseLocation as NSPoint?,
let panelFrame = self?.menubarPanel?.frame,
!NSMouseInRect(eventLocation, panelFrame, false) {
self?.hidePopover()
}
return event
}
}
}

Expand All @@ -205,6 +214,10 @@ class MenuBarManager: NSObject {
NSEvent.removeMonitor(monitor)
eventMonitor = nil
}
if let localMonitor = localEventMonitor {
NSEvent.removeMonitor(localMonitor)
localEventMonitor = nil
}
}
}

Expand Down
50 changes: 48 additions & 2 deletions airsync-mac/Core/QuickConnect/QuickConnectManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,23 @@ class QuickConnectManager: ObservableObject {
return
}

let isBLE = device.ipAddress == "Bluetooth LE" || device.ipAddress == "BLE"

DispatchQueue.main.async {
if isBLE {
if let existing = self.lastConnectedDevices[currentMacIP] {
let existingIsBLE = existing.ipAddress == "Bluetooth LE" || existing.ipAddress == "BLE"
if !existingIsBLE {
print("[quick-connect] Not overwriting existing Wi-Fi device (\(existing.name): \(existing.ipAddress)) with BLE device")
return
}
}
}

self.lastConnectedDevices[currentMacIP] = device
self.saveDeviceHistoryToDisk()
print("[quick-connect] Saved last connected device for network \(currentMacIP): \(device.name) (\(device.ipAddress))")
}
print("[quick-connect] Saved last connected device for network \(currentMacIP): \(device.name) (\(device.ipAddress))")
}

/// Clears the last connected device for the current network
Expand All @@ -64,8 +76,36 @@ class QuickConnectManager: ObservableObject {

/// Attempts to wake up and reconnect to a specific discovered device
func connect(to discoveredDevice: DiscoveredDevice) {
let hasWifiIPs = discoveredDevice.ips.contains { $0 != "Bluetooth LE" && !$0.isEmpty }
if (discoveredDevice.type == "ble" || discoveredDevice.ips.contains("Bluetooth LE")) && !hasWifiIPs {
print("[quick-connect] Discovered device is Bluetooth LE, connecting directly via BLE...")
// Show progress in UI
DispatchQueue.main.async {
self.connectingDeviceID = discoveredDevice.id
}

// Save as last connected device (so we can auto-reconnect later)
let device = Device(
name: discoveredDevice.name,
ipAddress: "Bluetooth LE",
port: discoveredDevice.port,
version: "Unknown",
adbPorts: [],
deviceId: discoveredDevice.deviceId
)
saveLastConnectedDevice(device)

BLECentralManager.shared.connectManually(toUuid: discoveredDevice.deviceId)

// Clear progress after short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
self.connectingDeviceID = nil
}
return
}

// Pick best IP: prefer local (non-100.x) over VPN
let bestIP = discoveredDevice.ips.first(where: { !$0.hasPrefix("100.") }) ?? discoveredDevice.ips.first ?? ""
let bestIP = discoveredDevice.ips.first(where: { !$0.hasPrefix("100.") && $0 != "Bluetooth LE" }) ?? discoveredDevice.ips.first(where: { $0 != "Bluetooth LE" }) ?? ""

// Convert DiscoveredDevice to Device model
let device = Device(
Expand Down Expand Up @@ -98,6 +138,12 @@ class QuickConnectManager: ObservableObject {
return
}

if lastDevice.ipAddress == "Bluetooth LE" {
print("[quick-connect] Last connected device is BLE. Initiating BLE reconnection...")
BLECentralManager.shared.connectManually(toUuid: lastDevice.deviceId)
return
}

print("[quick-connect] Attempting to wake up device: \(lastDevice.name) at \(lastDevice.ipAddress)")
print("[quick-connect] Will try HTTP port \(Self.ANDROID_HTTP_WAKEUP_PORT), then UDP port \(Self.ANDROID_UDP_WAKEUP_PORT) if needed")

Expand Down
Loading