From a6e072628b10bb53d9b9b985ec0711d6320b8497 Mon Sep 17 00:00:00 2001 From: Mudit200408 Date: Wed, 27 May 2026 12:17:18 +0530 Subject: [PATCH 1/9] feat(haptics): add trackpad haptic feedback to all seekbars and settings sliders --- .../PhoneView/DeviceStatusView.swift | 14 ++++++++++ .../PhoneView/MediaPlayerView.swift | 15 ++++++++++- .../Settings/MenubarSettingsView.swift | 27 +++++++++++++++++-- .../Settings/MirroringSettingsView.swift | 27 +++++++++++++++++++ 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift index 56048a0e..c61750cb 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/DeviceStatusView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AppKit struct DeviceStatusView: View { @ObservedObject var appState = AppState.shared @@ -74,10 +75,23 @@ struct DeviceStatusView: View { if !editing { WebSocketServer.shared.setVolume(Int(tempVolume)) } + if editing { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } else { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } isDragging = editing } ) .focusable(false) + .onChange(of: tempVolume) { oldValue, newValue in + guard isDragging else { return } + let lastTick = floor(oldValue / 5.0) * 5.0 + let currentTick = floor(newValue / 5.0) * 5.0 + if currentTick != lastTick { + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) + } + } Image(systemName: "speaker.wave.3.fill") } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift index e45e2d94..742ac2b3 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/MediaPlayerView.swift @@ -7,6 +7,7 @@ import SwiftUI import Combine +import AppKit // MARK: - Seekbar sub-view @@ -22,13 +23,25 @@ private struct MediaSeekbarView: View { in: 0...max(music.duration, 1), onEditingChanged: { editing in appState.isDraggingMedia = editing - if !editing { + if editing { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } else { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) appState.handleMediaSeek(to: appState.mediaPosition) } } ) .accentColor(.primary) .padding(.horizontal, 2) + .onChange(of: appState.mediaPosition) { oldValue, newValue in + guard appState.isDraggingMedia else { return } + let tickInterval: Double = 1.0 + let lastTick = floor(oldValue / tickInterval) * tickInterval + let currentTick = floor(newValue / tickInterval) * tickInterval + if currentTick != lastTick { + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) + } + } // Time labels HStack { diff --git a/airsync-mac/Screens/Settings/MenubarSettingsView.swift b/airsync-mac/Screens/Settings/MenubarSettingsView.swift index b46fa5fe..c711f17b 100644 --- a/airsync-mac/Screens/Settings/MenubarSettingsView.swift +++ b/airsync-mac/Screens/Settings/MenubarSettingsView.swift @@ -1,10 +1,13 @@ import SwiftUI +import AppKit struct MenubarSettingsView: View { @ObservedObject var appState = AppState.shared @State private var showingPlusPopover = false @State private var plusPopoverMessage = "" @State private var showMarqueeInfo = false + @State private var isDraggingFontSize = false + @State private var isDraggingTextLength = false var body: some View { ScrollView { @@ -17,10 +20,20 @@ struct MenubarSettingsView: View { Slider( value: $appState.menubarFontSize, in: 10...16, - step: 1 + step: 1, + onEditingChanged: { editing in + isDraggingFontSize = editing + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } ) .frame(width: 150) .controlSize(.small) + .onChange(of: appState.menubarFontSize) { oldValue, newValue in + guard isDraggingFontSize else { return } + if newValue != oldValue { + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) + } + } Text("\(Int(appState.menubarFontSize))") .font(.system(size: 11, design: .monospaced)) @@ -53,10 +66,20 @@ struct MenubarSettingsView: View { set: { appState.menubarTextMaxLength = Int($0) } ), in: 50...300, - step: 10 + step: 10, + onEditingChanged: { editing in + isDraggingTextLength = editing + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } ) .frame(width: 150) .controlSize(.small) + .onChange(of: appState.menubarTextMaxLength) { oldValue, newValue in + guard isDraggingTextLength else { return } + if newValue != oldValue { + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) + } + } Text("\(appState.menubarTextMaxLength)pt") .font(.system(size: 11, design: .monospaced)) diff --git a/airsync-mac/Screens/Settings/MirroringSettingsView.swift b/airsync-mac/Screens/Settings/MirroringSettingsView.swift index c5382107..7d841fb5 100644 --- a/airsync-mac/Screens/Settings/MirroringSettingsView.swift +++ b/airsync-mac/Screens/Settings/MirroringSettingsView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import AppKit struct MirroringSettingsView: View { @ObservedObject var appState = AppState.shared @@ -21,6 +22,8 @@ struct MirroringSettingsView: View { @State private var tempBitrate: Double = 4.00 @State private var tempResolution: Double = 1200.00 @State private var isDragging = false + @State private var isDraggingBitrate = false + @State private var isDraggingResolution = false @State private var xCoords: String = "0" @State private var yCoords: String = "0" @@ -88,11 +91,23 @@ struct MirroringSettingsView: View { if !editing { AppState.shared.scrcpyBitrate = Int(tempBitrate) } + if editing { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } else { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + isDraggingBitrate = editing isDragging = editing } ) .focusable(false) .frame(maxWidth: 150) + .onChange(of: tempBitrate) { oldValue, newValue in + guard isDraggingBitrate else { return } + if newValue != oldValue { + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) + } + } Text(String(format: L("settings.mirroring.bitrateFormat"), AppState.shared.scrcpyBitrate)) .monospacedDigit() @@ -112,11 +127,23 @@ struct MirroringSettingsView: View { if !editing { AppState.shared.scrcpyResolution = Int(tempResolution) } + if editing { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } else { + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) + } + isDraggingResolution = editing isDragging = editing } ) .focusable(false) .frame(maxWidth: 150) + .onChange(of: tempResolution) { oldValue, newValue in + guard isDraggingResolution else { return } + if newValue != oldValue { + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) + } + } Text("\(AppState.shared.scrcpyResolution)") .monospacedDigit() From c3a0b5a1689d2d6683df99bd0a2c1da1475bd1d0 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 29 May 2026 17:27:40 +0530 Subject: [PATCH 2/9] feat: What's new app tour --- .../Components/Custom/WhatsNewModifier.swift | 40 +++++ .../Custom/WhatsNewTourPopover.swift | 55 ++++++ .../Core/Storage/WhatsNewTourManager.swift | 166 ++++++++++++++++++ airsync-mac/Localization/en.json | 16 +- .../HomeScreen/AppsView/AppGridView.swift | 7 + .../NotificationView/NotificationView.swift | 10 ++ .../HomeScreen/PhoneView/ScreenView.swift | 10 ++ .../Screens/HomeScreen/SidebarView.swift | 10 ++ .../ScannerView/QRScannerSidebarView.swift | 2 + .../Screens/ScannerView/ScannerView.swift | 5 + .../Settings/Components/AboutView.swift | 1 + .../Settings/SettingsSidebarView.swift | 4 + 12 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 airsync-mac/Components/Custom/WhatsNewModifier.swift create mode 100644 airsync-mac/Components/Custom/WhatsNewTourPopover.swift create mode 100644 airsync-mac/Core/Storage/WhatsNewTourManager.swift diff --git a/airsync-mac/Components/Custom/WhatsNewModifier.swift b/airsync-mac/Components/Custom/WhatsNewModifier.swift new file mode 100644 index 00000000..217da734 --- /dev/null +++ b/airsync-mac/Components/Custom/WhatsNewModifier.swift @@ -0,0 +1,40 @@ +// +// WhatsNewModifier.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-29. +// + +import SwiftUI + +struct WhatsNewModifier: ViewModifier { + let item: WhatsNewTourItem + let arrowEdge: Edge + + @ObservedObject var tourManager = WhatsNewTourManager.shared + + func body(content: Content) -> some View { + content + .popover( + isPresented: Binding( + get: { tourManager.activeItem == item }, + set: { isPresented in + if !isPresented { + tourManager.dismiss(item) + } + } + ), + arrowEdge: arrowEdge + ) { + WhatsNewTourPopover(item: item) { + tourManager.dismiss(item) + } + } + } +} + +extension View { + func whatsNewPopover(item: WhatsNewTourItem, arrowEdge: Edge = .top) -> some View { + self.modifier(WhatsNewModifier(item: item, arrowEdge: arrowEdge)) + } +} diff --git a/airsync-mac/Components/Custom/WhatsNewTourPopover.swift b/airsync-mac/Components/Custom/WhatsNewTourPopover.swift new file mode 100644 index 00000000..8a936787 --- /dev/null +++ b/airsync-mac/Components/Custom/WhatsNewTourPopover.swift @@ -0,0 +1,55 @@ +// +// WhatsNewTourPopover.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-29. +// + +import SwiftUI + +struct WhatsNewTourPopover: View { + let item: WhatsNewTourItem + let onDismiss: () -> Void + + @State private var animateSparkles = false + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.accentColor) + .symbolEffect(.bounce, value: animateSparkles) + + Text(L(item.titleKey)) + .font(.headline) + .fontWeight(.bold) + } + .onAppear { + animateSparkles = true + } + + Text(L(item.messageKey)) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack { + Spacer() + + GlassButtonView( + label: "", + systemImage: "checkmark.circle", + iconOnly: true, + size: .large, + primary: false, + action: onDismiss + ) + } + } + .padding(16) + .frame(width: 280) + } +} diff --git a/airsync-mac/Core/Storage/WhatsNewTourManager.swift b/airsync-mac/Core/Storage/WhatsNewTourManager.swift new file mode 100644 index 00000000..6769a18f --- /dev/null +++ b/airsync-mac/Core/Storage/WhatsNewTourManager.swift @@ -0,0 +1,166 @@ +// +// WhatsNewTourManager.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-29. +// + +import Foundation +import Combine +import SwiftUI + +enum WhatsNewTourItem: String, CaseIterable { + case settings = "whatsnew_settings" + case scanQR = "whatsnew_scan_qr" + case nearbyDevices = "whatsnew_nearby_devices" + case connectionPill = "whatsnew_connection_pill" + case desktopMode = "whatsnew_desktop_mode" + case firstNotification = "whatsnew_first_notification" + case appsGrid = "whatsnew_apps_grid" + + var titleKey: String { + switch self { + case .settings: return "whatsnew.settings.title" + case .scanQR: return "whatsnew.scan.title" + case .nearbyDevices: return "whatsnew.nearby.title" + case .connectionPill: return "whatsnew.connection.title" + case .desktopMode: return "whatsnew.desktop.title" + case .firstNotification: return "whatsnew.notification.title" + case .appsGrid: return "whatsnew.apps.title" + } + } + + var messageKey: String { + switch self { + case .settings: return "whatsnew.settings.message" + case .scanQR: return "whatsnew.scan.message" + case .nearbyDevices: return "whatsnew.nearby.message" + case .connectionPill: return "whatsnew.connection.message" + case .desktopMode: return "whatsnew.desktop.message" + case .firstNotification: return "whatsnew.notification.message" + case .appsGrid: return "whatsnew.apps.message" + } + } +} + +class WhatsNewTourManager: ObservableObject { + static let shared = WhatsNewTourManager() + + @Published var activeItem: WhatsNewTourItem? = nil + + private var cancellables = Set() + + private init() { + // Observe AppState changes to re-evaluate active tour items dynamically + AppState.shared.$selectedTab + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.evaluateActiveItem() + } + .store(in: &cancellables) + + AppState.shared.$device + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.evaluateActiveItem() + } + .store(in: &cancellables) + + AppState.shared.$adbConnected + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.evaluateActiveItem() + } + .store(in: &cancellables) + + AppState.shared.$notifications + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.evaluateActiveItem() + } + .store(in: &cancellables) + + AppState.shared.$isOnboardingActive + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.evaluateActiveItem() + } + .store(in: &cancellables) + } + + func isDismissed(_ item: WhatsNewTourItem) -> Bool { + UserDefaults.standard.bool(forKey: item.rawValue + "_dismissed") + } + + func dismiss(_ item: WhatsNewTourItem) { + UserDefaults.standard.set(true, forKey: item.rawValue + "_dismissed") + evaluateActiveItem() + } + + func resetAll() { + for item in WhatsNewTourItem.allCases { + UserDefaults.standard.set(false, forKey: item.rawValue + "_dismissed") + } + evaluateActiveItem() + } + + private var hasNearbyDevices: Bool { + let hasUdp = !UDPDiscoveryManager.shared.discoveredDevices.isEmpty + let hasBle = AppState.shared.isBLEEnabled && !BLECentralManager.shared.discoveredBLEDevices.isEmpty + return hasUdp || hasBle + } + + func evaluateActiveItem() { + let appState = AppState.shared + + // Do not show any popovers until onboarding is fully completed + if UserDefaults.standard.needsOnboarding || appState.isOnboardingActive { + activeItem = nil + return + } + + // Settings Tab tour + if !isDismissed(.settings) && appState.selectedTab == .settings { + activeItem = .settings + return + } + + // Scan QR tour (when not connected and scanner view is active) + if !isDismissed(.scanQR) && appState.device == nil && appState.selectedTab == .qr { + activeItem = .scanQR + return + } + + // Nearby devices list tour (when not connected, scanner view is active, and nearby devices discovered) + if !isDismissed(.nearbyDevices) && appState.device == nil && appState.selectedTab == .qr && hasNearbyDevices { + activeItem = .nearbyDevices + return + } + + // Connection status pill tour (when connected, settings tab not active, home/screen view active) + if !isDismissed(.connectionPill) && appState.device != nil && appState.selectedTab != .settings { + activeItem = .connectionPill + return + } + + // Desktop Mode button tour (when connected, adb connected, settings tab not active, home/screen view active) + if !isDismissed(.desktopMode) && appState.device != nil && appState.adbConnected && appState.selectedTab != .settings { + activeItem = .desktopMode + return + } + + // Notifications list tour (when connected, notifications tab is active, and there's at least one notification) + if !isDismissed(.firstNotification) && appState.device != nil && appState.selectedTab == .notifications && !appState.notifications.isEmpty { + activeItem = .firstNotification + return + } + + // Apps grid tour (when connected, apps tab is active) + if !isDismissed(.appsGrid) && appState.device != nil && appState.selectedTab == .apps { + activeItem = .appsGrid + return + } + + activeItem = nil + } +} diff --git a/airsync-mac/Localization/en.json b/airsync-mac/Localization/en.json index 3f50c70b..2e0d4b73 100644 --- a/airsync-mac/Localization/en.json +++ b/airsync-mac/Localization/en.json @@ -146,5 +146,19 @@ "settings.pairing.howToPair": "How to pair?", "settings.pairing.instructions": "In order to pair your device, follow these steps:\n\n1. Go to Android device's Settings -> About phone\n2. Tap on \"Build number\" 7 times to unlcok developer options\n3. Go to Settings -> System -> Developer options\n4. Look for \"USB debugging\" and enable\n5. Look for \"Wireless debugging\", enable and go in\n6. Tap on \"Pair device with QR code\" option and scan the above QR code to authenticate the device. If prompted, Select \"Always allow on this network\" if prompted.\n\nDone! You are ready to connect.", "settings.pairing.troubleshooting": "Troubleshooting", - "settings.pairing.troubleshooting.text": "If pairing is failing, please check the following:\n\n• Ensure both devices are connected to the exact same Wi-Fi network.\n• Check if you have an active VPN on your Mac or Android device, which can block local network traffic (mDNS).\n• Turn Wireless Debugging off and back on in Android Developer options.\n• Check if your router blocks Multicast or local network discovery (AP Isolation)." + "settings.pairing.troubleshooting.text": "If pairing is failing, please check the following:\n\n• Ensure both devices are connected to the exact same Wi-Fi network.\n• Check if you have an active VPN on your Mac or Android device, which can block local network traffic (mDNS).\n• Turn Wireless Debugging off and back on in Android Developer options.\n• Check if your router blocks Multicast or local network discovery (AP Isolation).", + "whatsnew.settings.title": "Welcome to new settings!", + "whatsnew.settings.message": "Explore all your AirSync settings reorganized in a sleek, convenient new layout.", + "whatsnew.desktop.title": "Check out desktop mode!", + "whatsnew.desktop.message": "Launch desktop mode to experience your phone as a full desktop interface.", + "whatsnew.connection.title": "Control your connection here", + "whatsnew.connection.message": "Click this status pill to manage your connection settings, ADB ports, and more.", + "whatsnew.apps.title": "Launch or control apps", + "whatsnew.apps.message": "Click to mirror the app, or open context menu to mute notifications", + "whatsnew.notification.title": "Swipe to dismiss", + "whatsnew.notification.message": "Swipe right to hide or left to dismiss from both devices", + "whatsnew.scan.title": "Scan to connect", + "whatsnew.scan.message": "Everything is encrypted with the secure key which is shared during authentication by scanning this QR code", + "whatsnew.nearby.title": "Nearby devices show up here", + "whatsnew.nearby.message": "Click to connect", } diff --git a/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift b/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift index 9d58486a..de98e335 100644 --- a/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift +++ b/airsync-mac/Screens/HomeScreen/AppsView/AppGridView.swift @@ -38,6 +38,7 @@ struct AppGridView: View { } .padding(12) } + .whatsNewPopover(item: .appsGrid, arrowEdge: .top) } .searchable( text: $searchText, @@ -45,6 +46,12 @@ struct AppGridView: View { prompt: "Search Apps" ) .padding(0) + .onAppear { + WhatsNewTourManager.shared.evaluateActiveItem() + } + .onChange(of: appState.selectedTab) { _, _ in + WhatsNewTourManager.shared.evaluateActiveItem() + } } } diff --git a/airsync-mac/Screens/HomeScreen/NotificationView/NotificationView.swift b/airsync-mac/Screens/HomeScreen/NotificationView/NotificationView.swift index 91a0a399..930cc00c 100644 --- a/airsync-mac/Screens/HomeScreen/NotificationView/NotificationView.swift +++ b/airsync-mac/Screens/HomeScreen/NotificationView/NotificationView.swift @@ -31,6 +31,16 @@ struct NotificationView: View { .accessibilityHidden(notificationStacks) .animation(.easeInOut(duration: 0.5), value: notificationStacks) } + .whatsNewPopover(item: .firstNotification, arrowEdge: .top) + .onAppear { + WhatsNewTourManager.shared.evaluateActiveItem() + } + .onChange(of: appState.notifications.count) { _, _ in + WhatsNewTourManager.shared.evaluateActiveItem() + } + .onChange(of: appState.selectedTab) { _, _ in + WhatsNewTourManager.shared.evaluateActiveItem() + } } else { NotificationEmptyView() } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift index 8fff06cd..97761c14 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ScreenView.swift @@ -16,6 +16,7 @@ struct ScreenView: View { VStack { ConnectionStatusPill() .padding(.top, 4) + .whatsNewPopover(item: .connectionPill, arrowEdge: .top) ConnectionStateView() .padding(.top, 4) @@ -117,6 +118,15 @@ struct ScreenView: View { } .padding(8) + .onAppear { + WhatsNewTourManager.shared.evaluateActiveItem() + } + .onChange(of: appState.device) { _, _ in + WhatsNewTourManager.shared.evaluateActiveItem() + } + .onChange(of: appState.selectedTab) { _, _ in + WhatsNewTourManager.shared.evaluateActiveItem() + } .animation( .easeInOut(duration: 0.35), value: AppState.shared.adbConnected diff --git a/airsync-mac/Screens/HomeScreen/SidebarView.swift b/airsync-mac/Screens/HomeScreen/SidebarView.swift index e1ffbd61..98072f7d 100644 --- a/airsync-mac/Screens/HomeScreen/SidebarView.swift +++ b/airsync-mac/Screens/HomeScreen/SidebarView.swift @@ -114,12 +114,22 @@ struct SidebarView: View { .popover(isPresented: $showingPlusDesktopPopover, arrowEdge: .top) { PlusFeaturePopover(message: "Desktop Mode is an AirSync+ feature") } + .whatsNewPopover(item: .desktopMode, arrowEdge: .top) } .padding(.top, 8) .padding(.bottom, 12) .transition(.opacity.combined(with: .move(edge: .bottom))) } } + .onAppear { + WhatsNewTourManager.shared.evaluateActiveItem() + } + .onChange(of: appState.adbConnected) { _, _ in + WhatsNewTourManager.shared.evaluateActiveItem() + } + .onChange(of: appState.selectedTab) { _, _ in + WhatsNewTourManager.shared.evaluateActiveItem() + } } } diff --git a/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift b/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift index 569c5da4..a844e305 100644 --- a/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift +++ b/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift @@ -36,6 +36,7 @@ struct QRScannerSidebarView: View { Text("Scan to connect") .font(.title3) .fontWeight(.bold) + .whatsNewPopover(item: .scanQR, arrowEdge: .trailing) .padding(.horizontal, 16) .padding(.top, 16) @@ -164,6 +165,7 @@ struct QRScannerSidebarView: View { .padding(.horizontal, 8) .onAppear { qrManager.generateQRAsync() + WhatsNewTourManager.shared.evaluateActiveItem() } .onDisappear { qrManager.cleanUpTimer() diff --git a/airsync-mac/Screens/ScannerView/ScannerView.swift b/airsync-mac/Screens/ScannerView/ScannerView.swift index 79d0eacb..28163093 100644 --- a/airsync-mac/Screens/ScannerView/ScannerView.swift +++ b/airsync-mac/Screens/ScannerView/ScannerView.swift @@ -86,6 +86,7 @@ struct ScannerView: View { Text("Available Devices") .font(.headline) .foregroundColor(.secondary) + .whatsNewPopover(item: .nearbyDevices, arrowEdge: .top) Spacer() } .transition(.opacity) @@ -179,10 +180,14 @@ struct ScannerView: View { .onAppear { // Refresh device info for current network on load quickConnectManager.refreshDeviceForCurrentNetwork() + WhatsNewTourManager.shared.evaluateActiveItem() } .onChange(of: appState.selectedNetworkAdapterName) { _, _ in quickConnectManager.refreshDeviceForCurrentNetwork() } + .onChange(of: allDiscoveredDevices) { _, _ in + WhatsNewTourManager.shared.evaluateActiveItem() + } } } diff --git a/airsync-mac/Screens/Settings/Components/AboutView.swift b/airsync-mac/Screens/Settings/Components/AboutView.swift index a3c3be13..1e21c438 100644 --- a/airsync-mac/Screens/Settings/Components/AboutView.swift +++ b/airsync-mac/Screens/Settings/Components/AboutView.swift @@ -111,6 +111,7 @@ struct AboutView: View { UserDefaults.standard.hasPairedDeviceOnce = false UserDefaults.standard.resetOnboarding() } + WhatsNewTourManager.shared.resetAll() } ) diff --git a/airsync-mac/Screens/Settings/SettingsSidebarView.swift b/airsync-mac/Screens/Settings/SettingsSidebarView.swift index ad2fd749..9ca9cc17 100644 --- a/airsync-mac/Screens/Settings/SettingsSidebarView.swift +++ b/airsync-mac/Screens/Settings/SettingsSidebarView.swift @@ -19,6 +19,7 @@ struct SettingsSidebarView: View { .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 12) + .whatsNewPopover(item: .settings, arrowEdge: .trailing) ScrollView { VStack(spacing: 4) { @@ -44,6 +45,9 @@ struct SettingsSidebarView: View { .padding(.bottom, 12) } .frame(minWidth: 260) + .onAppear { + WhatsNewTourManager.shared.evaluateActiveItem() + } } @ViewBuilder From 631374001df83dc43ce200fa964f261e2acd4016 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Fri, 29 May 2026 17:30:42 +0530 Subject: [PATCH 3/9] refactor: wrap evaluateActiveItem logic in main thread dispatch to ensure safe UI updates --- .../Core/Storage/WhatsNewTourManager.swift | 103 +++++++++--------- 1 file changed, 53 insertions(+), 50 deletions(-) diff --git a/airsync-mac/Core/Storage/WhatsNewTourManager.swift b/airsync-mac/Core/Storage/WhatsNewTourManager.swift index 6769a18f..d4db0fae 100644 --- a/airsync-mac/Core/Storage/WhatsNewTourManager.swift +++ b/airsync-mac/Core/Storage/WhatsNewTourManager.swift @@ -111,56 +111,59 @@ class WhatsNewTourManager: ObservableObject { } func evaluateActiveItem() { - let appState = AppState.shared - - // Do not show any popovers until onboarding is fully completed - if UserDefaults.standard.needsOnboarding || appState.isOnboardingActive { - activeItem = nil - return - } - - // Settings Tab tour - if !isDismissed(.settings) && appState.selectedTab == .settings { - activeItem = .settings - return - } - - // Scan QR tour (when not connected and scanner view is active) - if !isDismissed(.scanQR) && appState.device == nil && appState.selectedTab == .qr { - activeItem = .scanQR - return - } - - // Nearby devices list tour (when not connected, scanner view is active, and nearby devices discovered) - if !isDismissed(.nearbyDevices) && appState.device == nil && appState.selectedTab == .qr && hasNearbyDevices { - activeItem = .nearbyDevices - return - } - - // Connection status pill tour (when connected, settings tab not active, home/screen view active) - if !isDismissed(.connectionPill) && appState.device != nil && appState.selectedTab != .settings { - activeItem = .connectionPill - return - } - - // Desktop Mode button tour (when connected, adb connected, settings tab not active, home/screen view active) - if !isDismissed(.desktopMode) && appState.device != nil && appState.adbConnected && appState.selectedTab != .settings { - activeItem = .desktopMode - return - } - - // Notifications list tour (when connected, notifications tab is active, and there's at least one notification) - if !isDismissed(.firstNotification) && appState.device != nil && appState.selectedTab == .notifications && !appState.notifications.isEmpty { - activeItem = .firstNotification - return - } - - // Apps grid tour (when connected, apps tab is active) - if !isDismissed(.appsGrid) && appState.device != nil && appState.selectedTab == .apps { - activeItem = .appsGrid - return + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + let appState = AppState.shared + + // Do not show any popovers until onboarding is fully completed + if UserDefaults.standard.needsOnboarding || appState.isOnboardingActive { + self.activeItem = nil + return + } + + // Settings Tab tour + if !self.isDismissed(.settings) && appState.selectedTab == .settings { + self.activeItem = .settings + return + } + + // Scan QR tour (when not connected and scanner view is active) + if !self.isDismissed(.scanQR) && appState.device == nil && appState.selectedTab == .qr { + self.activeItem = .scanQR + return + } + + // Nearby devices list tour (when not connected, scanner view is active, and nearby devices discovered) + if !self.isDismissed(.nearbyDevices) && appState.device == nil && appState.selectedTab == .qr && self.hasNearbyDevices { + self.activeItem = .nearbyDevices + return + } + + // Connection status pill tour (when connected, settings tab not active, home/screen view active) + if !self.isDismissed(.connectionPill) && appState.device != nil && appState.selectedTab != .settings { + self.activeItem = .connectionPill + return + } + + // Desktop Mode button tour (when connected, adb connected, settings tab not active, home/screen view active) + if !self.isDismissed(.desktopMode) && appState.device != nil && appState.adbConnected && appState.selectedTab != .settings { + self.activeItem = .desktopMode + return + } + + // Notifications list tour (when connected, notifications tab is active, and there's at least one notification) + if !self.isDismissed(.firstNotification) && appState.device != nil && appState.selectedTab == .notifications && !appState.notifications.isEmpty { + self.activeItem = .firstNotification + return + } + + // Apps grid tour (when connected, apps tab is active) + if !self.isDismissed(.appsGrid) && appState.device != nil && appState.selectedTab == .apps { + self.activeItem = .appsGrid + return + } + + self.activeItem = nil } - - activeItem = nil } } From b1c92c211ea1db3b24d2a0126b6a5b98289fa65e Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 3 Jun 2026 07:03:05 +0530 Subject: [PATCH 4/9] fix: Marquee text toggle for menubar --- .../Settings/MenubarSettingsView.swift | 103 +++++++++--------- 1 file changed, 51 insertions(+), 52 deletions(-) diff --git a/airsync-mac/Screens/Settings/MenubarSettingsView.swift b/airsync-mac/Screens/Settings/MenubarSettingsView.swift index c711f17b..6ab5b3d2 100644 --- a/airsync-mac/Screens/Settings/MenubarSettingsView.swift +++ b/airsync-mac/Screens/Settings/MenubarSettingsView.swift @@ -55,65 +55,64 @@ struct MenubarSettingsView: View { .toggleStyle(.switch) } - if appState.showMenubarText { - VStack(spacing: 12) { - HStack { - Label(L("settings.menubar.maxLength"), systemImage: "arrow.left.and.right") - Spacer() - Slider( - value: Binding( - get: { Double(appState.menubarTextMaxLength) }, - set: { appState.menubarTextMaxLength = Int($0) } - ), - in: 50...300, - step: 10, - onEditingChanged: { editing in - isDraggingTextLength = editing - NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) - } - ) - .frame(width: 150) - .controlSize(.small) - .onChange(of: appState.menubarTextMaxLength) { oldValue, newValue in - guard isDraggingTextLength else { return } - if newValue != oldValue { - NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) - } - } - - Text("\(appState.menubarTextMaxLength)pt") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(.secondary) - .frame(width: 36, alignment: .trailing) - } - - HStack { - Label(L("settings.menubar.enableMarquee"), systemImage: "play.right.to.left") - Button(action: { showMarqueeInfo = true }) { - Image(systemName: "info.circle") - .foregroundStyle(.secondary) + VStack(spacing: 12) { + HStack { + Label(L("settings.menubar.maxLength"), systemImage: "arrow.left.and.right") + Spacer() + Slider( + value: Binding( + get: { Double(appState.menubarTextMaxLength) }, + set: { appState.menubarTextMaxLength = Int($0) } + ), + in: 50...300, + step: 10, + onEditingChanged: { editing in + isDraggingTextLength = editing + NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default) } - .buttonStyle(.plain) - .alert(L("settings.menubar.enableMarquee"), isPresented: $showMarqueeInfo) { - Button("OK", role: .cancel) {} - } message: { - Text(L("settings.menubar.enableMarquee.info")) + ) + .frame(width: 150) + .controlSize(.small) + .onChange(of: appState.menubarTextMaxLength) { oldValue, newValue in + guard isDraggingTextLength else { return } + if newValue != oldValue { + NSHapticFeedbackManager.defaultPerformer.perform(.levelChange, performanceTime: .default) } - - Spacer() - Toggle("", isOn: $appState.enableMarquee) - .toggleStyle(.switch) } + + Text("\(appState.menubarTextMaxLength)pt") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + .frame(width: 36, alignment: .trailing) + } - HStack { - Label(L("settings.menubar.showDeviceName"), systemImage: "iphone.gen3") - Spacer() - Toggle("", isOn: $appState.showMenubarDeviceName) - .toggleStyle(.switch) + HStack { + Label(L("settings.menubar.enableMarquee"), systemImage: "play.right.to.left") + Button(action: { showMarqueeInfo = true }) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) } + .buttonStyle(.plain) + .alert(L("settings.menubar.enableMarquee"), isPresented: $showMarqueeInfo) { + Button("OK", role: .cancel) {} + } message: { + Text(L("settings.menubar.enableMarquee.info")) + } + + Spacer() + Toggle("", isOn: $appState.enableMarquee) + .toggleStyle(.switch) + } + + HStack { + Label(L("settings.menubar.showDeviceName"), systemImage: "iphone.gen3") + Spacer() + Toggle("", isOn: $appState.showMenubarDeviceName) + .toggleStyle(.switch) } - .transition(.opacity.combined(with: .move(edge: .top))) } + .disabled(!appState.showMenubarText) + .opacity(appState.showMenubarText ? 1.0 : 0.6) HStack { Label(L("settings.menubar.batteryStyle"), systemImage: "battery.100") From f0cc4e9644a079d10e8756594287501945aef28e Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 3 Jun 2026 07:20:50 +0530 Subject: [PATCH 5/9] fix: update keyboard shortcut for sidebar toggle from P to D --- airsync-mac/Screens/HomeScreen/SidebarView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/airsync-mac/Screens/HomeScreen/SidebarView.swift b/airsync-mac/Screens/HomeScreen/SidebarView.swift index 98072f7d..b691ec2f 100644 --- a/airsync-mac/Screens/HomeScreen/SidebarView.swift +++ b/airsync-mac/Screens/HomeScreen/SidebarView.swift @@ -90,7 +90,7 @@ struct SidebarView: View { } } .keyboardShortcut( - "p", + "d", modifiers: [.command, .shift] ) From 8c76ea02fc6e49f5d140666dbe49d737f5da4faa Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 3 Jun 2026 07:47:02 +0530 Subject: [PATCH 6/9] feat: add ADB device picker --- .../Buttons/SaveAndRestartButton.swift | 3 +- airsync-mac/Core/AppState.swift | 21 ++++++- .../QuickConnect/QuickConnectManager.swift | 3 +- airsync-mac/Core/Util/CLI/ADBConnector.swift | 57 ++++++++++++++---- .../WebSocket/WebSocketServer+Handlers.swift | 59 +++++++++++++++---- airsync-mac/Localization/en.json | 3 + airsync-mac/Model/Device.swift | 12 ++-- .../PhoneView/ConnectionStatusPill.swift | 41 +++++++++++-- 8 files changed, 161 insertions(+), 38 deletions(-) diff --git a/airsync-mac/Components/Buttons/SaveAndRestartButton.swift b/airsync-mac/Components/Buttons/SaveAndRestartButton.swift index 99cc39d5..f9200297 100644 --- a/airsync-mac/Components/Buttons/SaveAndRestartButton.swift +++ b/airsync-mac/Components/Buttons/SaveAndRestartButton.swift @@ -32,7 +32,8 @@ struct SaveAndRestartButton: View { ipAddress: ipAddress, port: Int(portNumber), version: version, - adbPorts: [] + adbPorts: [], + deviceId: UserDefaults.standard.string(forKey: "trialDeviceIdentifier") ?? "mac_device" ) // Save diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 7167a03c..3d9304c2 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -29,6 +29,9 @@ class AppState: ObservableObject { @Published var isOS26: Bool = true init() { + self.deviceAdbSerials = UserDefaults.standard.dictionary(forKey: "deviceAdbSerials") as? [String: String] ?? [:] + self.selectedWiredSerial = UserDefaults.standard.string(forKey: "selectedWiredSerial") + let isPlusLoaded = UserDefaults.standard.bool(forKey: "isPlus") self.isPlus = isPlusLoaded @@ -119,7 +122,8 @@ class AppState: ObservableObject { ipAddress: adapterIP, port: portNum, version: appVersion, - adbPorts: [] + adbPorts: [], + deviceId: UserDefaults.standard.string(forKey: "trialDeviceIdentifier") ?? "mac_device" ) self.licenseDetails = AppState.loadLicenseDetailsFromUserDefaults() @@ -327,6 +331,18 @@ class AppState: ObservableObject { UserDefaults.standard.set(selectedNetworkAdapterName, forKey: "selectedNetworkAdapterName") } } + @Published var deviceAdbSerials: [String: String] { + didSet { + UserDefaults.standard.set(deviceAdbSerials, forKey: "deviceAdbSerials") + } + } + + @Published var selectedWiredSerial: String? { + didSet { + UserDefaults.standard.set(selectedWiredSerial, forKey: "selectedWiredSerial") + } + } + @Published var showMenubarText: Bool { didSet { UserDefaults.standard.set(showMenubarText, forKey: "showMenubarText") @@ -1546,7 +1562,8 @@ class AppState: ObservableObject { ipAddress: "BLE", port: 0, version: "2.0.0", - adbPorts: [] + adbPorts: [], + deviceId: BLECentralManager.shared.connectingDeviceUUID ?? "ble_device" ) print("[state] (BLE) Created virtual device: \(name)") } diff --git a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift index c5ff217c..e260371b 100644 --- a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift +++ b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift @@ -73,7 +73,8 @@ class QuickConnectManager: ObservableObject { ipAddress: bestIP, port: discoveredDevice.port, version: "Unknown", - adbPorts: [] + adbPorts: [], + deviceId: discoveredDevice.deviceId ) saveLastConnectedDevice(device) diff --git a/airsync-mac/Core/Util/CLI/ADBConnector.swift b/airsync-mac/Core/Util/CLI/ADBConnector.swift index a8b76649..51f8ae61 100644 --- a/airsync-mac/Core/Util/CLI/ADBConnector.swift +++ b/airsync-mac/Core/Util/CLI/ADBConnector.swift @@ -8,6 +8,12 @@ import Foundation import AppKit +struct WiredADBDevice: Hashable, Identifiable { + var id: String { serial } + let serial: String + let model: String +} + struct ADBConnector { // Potential fallback paths @@ -78,10 +84,10 @@ struct ADBConnector { print("[adb-connector] (Binary Detection) \(message)") } - static func getWiredDeviceSerial(completion: @escaping (String?) -> Void) { + static func getWiredDevices(completion: @escaping ([WiredADBDevice]) -> Void) { DispatchQueue.global(qos: .userInitiated).async { guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { - completion(nil) + completion([]) return } @@ -100,21 +106,39 @@ struct ADBConnector { let output = String(data: data, encoding: .utf8) ?? "" let lines = output.components(separatedBy: .newlines) + var devices: [WiredADBDevice] = [] for line in lines { if line.contains("device") && line.contains("usb:") { let parts = line.split(separator: " ").filter { !$0.isEmpty } if !parts.isEmpty { let serial = String(parts[0]) - logBinaryDetection("Detected wired ADB device: \(serial)") - completion(serial) - return + var model = "Unknown Device" + for part in parts { + if part.hasPrefix("model:") { + model = part.replacingOccurrences(of: "model:", with: "").replacingOccurrences(of: "_", with: " ") + break + } + } + devices.append(WiredADBDevice(serial: serial, model: model)) } } } + completion(devices) } catch { print("[adb-connector] Error getting wired devices: \(error)") + completion([]) + } + } + } + + static func getWiredDeviceSerial(completion: @escaping (String?) -> Void) { + getWiredDevices { devices in + if let first = devices.first { + logBinaryDetection("Detected wired ADB device: \(first.serial)") + completion(first.serial) + } else { + completion(nil) } - completion(nil) } } @@ -398,9 +422,12 @@ struct ADBConnector { "--no-power-on" ] - getWiredDeviceSerial { serial in + let mappedSerial = AppState.shared.selectedWiredSerial ?? (AppState.shared.device?.deviceId).flatMap { AppState.shared.deviceAdbSerials[$0] } + + getWiredDevices { devices in + let serialToUse = mappedSerial ?? devices.first?.serial DispatchQueue.global(qos: .userInitiated).async { - if wiredAdbEnabled, let serial = serial { + if wiredAdbEnabled, let serial = serialToUse { args.append("--serial=\(serial)") DispatchQueue.main.async { AppState.shared.adbConnectionMode = .wired } logBinaryDetection("Wired ADB prioritized: using serial \(serial)") @@ -489,7 +516,10 @@ struct ADBConnector { AppState.shared.isADBTransferring = true AppState.shared.adbTransferringFilePath = remotePath - getWiredDeviceSerial { serial in + let mappedSerial = AppState.shared.selectedWiredSerial ?? (AppState.shared.device?.deviceId).flatMap { AppState.shared.deviceAdbSerials[$0] } + + getWiredDevices { devices in + let serialToUse = mappedSerial ?? devices.first?.serial DispatchQueue.global(qos: .userInitiated).async { guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { DispatchQueue.main.async { AppState.shared.isADBTransferring = false } @@ -498,7 +528,7 @@ struct ADBConnector { } var args = ["pull", remotePath, destiny] - if wiredAdbEnabled, let serial = serial { + if wiredAdbEnabled, let serial = serialToUse { args.insert(contentsOf: ["-s", serial], at: 0) } else { args.insert(contentsOf: ["-s", fullAddress], at: 0) @@ -529,7 +559,10 @@ struct ADBConnector { AppState.shared.isADBTransferring = true AppState.shared.adbTransferringFilePath = remotePath - getWiredDeviceSerial { serial in + let mappedSerial = AppState.shared.selectedWiredSerial ?? (AppState.shared.device?.deviceId).flatMap { AppState.shared.deviceAdbSerials[$0] } + + getWiredDevices { devices in + let serialToUse = mappedSerial ?? devices.first?.serial DispatchQueue.global(qos: .userInitiated).async { guard let adbPath = findExecutable(named: "adb", fallbackPaths: possibleADBPaths) else { DispatchQueue.main.async { AppState.shared.isADBTransferring = false } @@ -538,7 +571,7 @@ struct ADBConnector { } var args = ["push", localPath, remotePath] - if wiredAdbEnabled, let serial = serial { + if wiredAdbEnabled, let serial = serialToUse { args.insert(contentsOf: ["-s", serial], at: 0) } else { args.insert(contentsOf: ["-s", fullAddress], at: 0) diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 87dbb334..9e6c41ce 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -107,13 +107,15 @@ extension WebSocketServer { let version = dict["version"] as? String ?? "2.0.0" let adbPorts = dict["adbPorts"] as? [String] ?? [] + let deviceId = dict["id"] as? String ?? "" AppState.shared.device = Device( name: name, ipAddress: ip, port: port, version: version, - adbPorts: adbPorts + adbPorts: adbPorts, + deviceId: deviceId ) if let base64 = dict["wallpaper"] as? String { @@ -143,22 +145,55 @@ extension WebSocketServer { if (!AppState.shared.adbConnected && (AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending || AppState.shared.wiredAdbEnabled) && AppState.shared.isPlus) { if AppState.shared.wiredAdbEnabled { - ADBConnector.getWiredDeviceSerial(completion: { serial in - if let serial = serial { - DispatchQueue.main.async { - AppState.shared.adbConnected = true - AppState.shared.adbConnectionMode = .wired - AppState.shared.adbConnectionResult = "Connected via Wired ADB (Serial: \(serial))" - AppState.shared.manualAdbConnectionPending = false + ADBConnector.getWiredDevices { wiredDevices in + let mappedSerial = AppState.shared.selectedWiredSerial ?? AppState.shared.deviceAdbSerials[deviceId] + + if !wiredDevices.isEmpty { + if let mappedSerial = mappedSerial { + if let matchedDevice = wiredDevices.first(where: { $0.serial == mappedSerial }) { + DispatchQueue.main.async { + AppState.shared.selectedWiredSerial = matchedDevice.serial + AppState.shared.adbConnected = true + AppState.shared.adbConnectionMode = .wired + AppState.shared.adbConnectionResult = "Connected via Wired ADB (Serial: \(matchedDevice.serial))" + AppState.shared.manualAdbConnectionPending = false + } + } else { + if AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending { + ADBConnector.connectToADB(ip: ip) + } + DispatchQueue.main.async { + AppState.shared.manualAdbConnectionPending = false + } + } + } else { + if wiredDevices.count == 1, let singleDevice = wiredDevices.first { + DispatchQueue.main.async { + AppState.shared.deviceAdbSerials[deviceId] = singleDevice.serial + AppState.shared.selectedWiredSerial = singleDevice.serial + AppState.shared.adbConnected = true + AppState.shared.adbConnectionMode = .wired + AppState.shared.adbConnectionResult = "Connected via Wired ADB (Serial: \(singleDevice.serial))" + AppState.shared.manualAdbConnectionPending = false + } + } else { + if AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending { + ADBConnector.connectToADB(ip: ip) + } + DispatchQueue.main.async { + AppState.shared.manualAdbConnectionPending = false + } + } + } + } else { + if AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending { + ADBConnector.connectToADB(ip: ip) } - } else if AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending { - // Try wireless connection if wired failed or no device found - ADBConnector.connectToADB(ip: ip) DispatchQueue.main.async { AppState.shared.manualAdbConnectionPending = false } } - }) + } } else if AppState.shared.adbEnabled || AppState.shared.manualAdbConnectionPending { // Try wireless connection directly ADBConnector.connectToADB(ip: ip) diff --git a/airsync-mac/Localization/en.json b/airsync-mac/Localization/en.json index 2e0d4b73..8af4550b 100644 --- a/airsync-mac/Localization/en.json +++ b/airsync-mac/Localization/en.json @@ -161,4 +161,7 @@ "whatsnew.scan.message": "Everything is encrypted with the secure key which is shared during authentication by scanning this QR code", "whatsnew.nearby.title": "Nearby devices show up here", "whatsnew.nearby.message": "Click to connect", + "connection.wiredAdb": "Wired ADB", + "connection.selectDevice": "Select Device", + "connection.wireless": "Wireless" } diff --git a/airsync-mac/Model/Device.swift b/airsync-mac/Model/Device.swift index d2631958..c9b2539c 100644 --- a/airsync-mac/Model/Device.swift +++ b/airsync-mac/Model/Device.swift @@ -15,9 +15,10 @@ struct Device: Codable, Hashable, Identifiable { let port: Int let version: String let adbPorts: [String] + let deviceId: String private enum CodingKeys: String, CodingKey { - case name, ipAddress, port, version, adbPorts + case name, ipAddress, port, version, adbPorts, deviceId } } @@ -27,7 +28,8 @@ struct MockData{ ipAddress: "192.168.1.100", port: 8080, version: "2.0.0", - adbPorts: ["5555"] + adbPorts: ["5555"], + deviceId: "test_device_id" ) static let sampleNotificaiton = Notification( @@ -54,8 +56,8 @@ struct MockData{ ) static let sampleDevices = [ - Device(name: "Test Device 1", ipAddress: "192.168.1.101", port: 8080, version: "2.0.0", adbPorts: ["5555"]), - Device(name: "Test Device 2", ipAddress: "192.168.1.102", port: 8080, version: "2.0.0", adbPorts: ["5555"]), - Device(name: "Test Device 3", ipAddress: "192.168.1.103", port: 8080, version: "2.0.0", adbPorts: ["5555"]) + Device(name: "Test Device 1", ipAddress: "192.168.1.101", port: 8080, version: "2.0.0", adbPorts: ["5555"], deviceId: "device_id_1"), + Device(name: "Test Device 2", ipAddress: "192.168.1.102", port: 8080, version: "2.0.0", adbPorts: ["5555"], deviceId: "device_id_2"), + Device(name: "Test Device 3", ipAddress: "192.168.1.103", port: 8080, version: "2.0.0", adbPorts: ["5555"], deviceId: "device_id_3") ] } diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 7c1814c9..af0df3f5 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -117,6 +117,8 @@ struct ConnectionPillPopover: View { @ObservedObject var bleManager = BLECentralManager.shared @State private var currentIPAddress: String = "N/A" + @State private var availableWiredDevices: [WiredADBDevice] = [] + var bleStatusText: String { switch bleManager.connectionStatus { case .scanning: return "Scanning..." @@ -147,11 +149,35 @@ struct ConnectionPillPopover: View { ) if appState.isPlus && appState.adbConnected { - ConnectionInfoText( - label: "ADB Connection", - icon: appState.adbConnectionMode == .wired ? "cable.connector" : "airplay.audio", - text: appState.adbConnectionMode == .wired ? "Wired (USB)" : "Wireless" - ) + if appState.adbConnectionMode == .wired { + HStack { + Label(L("connection.wiredAdb"), systemImage: "cable.connector") + Spacer() + Picker("", selection: Binding( + get: { appState.selectedWiredSerial ?? "" }, + set: { newSerial in + appState.selectedWiredSerial = newSerial + if let deviceId = appState.device?.deviceId { + appState.deviceAdbSerials[deviceId] = newSerial + } + appState.adbConnectionResult = "Switched to Wired ADB Serial: \(newSerial)" + } + )) { + Text(loc: "connection.selectDevice").tag("") + ForEach(availableWiredDevices, id: \.serial) { dev in + Text("\(dev.model) (\(dev.serial))").tag(dev.serial) + } + } + .pickerStyle(MenuPickerStyle()) + .frame(width: 140) + } + } else { + ConnectionInfoText( + label: "ADB Connection", + icon: "airplay.audio", + text: L("connection.wireless") + ) + } } HStack { @@ -253,6 +279,11 @@ struct ConnectionPillPopover: View { .padding() .onAppear { currentIPAddress = WebSocketServer.shared.getLocalIPAddress(adapterName: appState.selectedNetworkAdapterName) ?? "N/A" + ADBConnector.getWiredDevices { devices in + DispatchQueue.main.async { + self.availableWiredDevices = devices + } + } } } } From 0687dd093857a91133a854c643dae3689f9e508b Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 3 Jun 2026 08:09:58 +0530 Subject: [PATCH 7/9] fix: Display file browser icon in menubar only while dav connected --- airsync-mac/Core/Storage/WebDAVManager.swift | 9 ++++++++- .../Screens/MenubarView/MenubarSegments.swift | 20 ++++++++++--------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/airsync-mac/Core/Storage/WebDAVManager.swift b/airsync-mac/Core/Storage/WebDAVManager.swift index 719b9263..bfe8c6b1 100644 --- a/airsync-mac/Core/Storage/WebDAVManager.swift +++ b/airsync-mac/Core/Storage/WebDAVManager.swift @@ -7,6 +7,7 @@ import Foundation import Cocoa +import Combine class WebDAVManager { static let shared = WebDAVManager() @@ -14,7 +15,7 @@ class WebDAVManager { private let mountPoint = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/Caches/com.airsync.mac/AndroidVolume") - private var isMounted = false + private(set) var isMounted = false private init() {} @@ -51,6 +52,9 @@ class WebDAVManager { if process.terminationStatus == 0 { self.isMounted = true print("[webdav] Successfully mounted Android volume") + DispatchQueue.main.async { + AppState.shared.objectWillChange.send() + } } else { print("[webdav] Failed to mount WebDAV volume. Status: \(process.terminationStatus)") } @@ -64,6 +68,9 @@ class WebDAVManager { DispatchQueue.global(qos: .userInitiated).async { self.unmountSilently() self.isMounted = false + DispatchQueue.main.async { + AppState.shared.objectWillChange.send() + } } } diff --git a/airsync-mac/Screens/MenubarView/MenubarSegments.swift b/airsync-mac/Screens/MenubarView/MenubarSegments.swift index 60528b18..b0ea33a2 100644 --- a/airsync-mac/Screens/MenubarView/MenubarSegments.swift +++ b/airsync-mac/Screens/MenubarView/MenubarSegments.swift @@ -74,15 +74,17 @@ struct TopSegmentView: View { } ) - GlassButtonView( - label: L("menu.browseFiles"), - systemImage: "folder", - iconOnly: true, - circleSize: toolButtonSize, - action: { - WebDAVManager.shared.openInFinder() - } - ) + if appState.isFileAccessEnabled && WebDAVManager.shared.isMounted { + GlassButtonView( + label: L("menu.browseFiles"), + systemImage: "folder", + iconOnly: true, + circleSize: toolButtonSize, + action: { + WebDAVManager.shared.openInFinder() + } + ) + } if appState.adbConnected && (appState.isPlus || !appState.licenseCheck) { GlassButtonView( From fcb9fde7b8d6aa9fa61d4e99c9cca29767f389e3 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 3 Jun 2026 12:46:09 +0530 Subject: [PATCH 8/9] feat: add floating navigation and control bars for mirroring --- .../Screens/Remote/FloatingNavbar.swift | 79 ++++++++++++++++ .../Remote/NavbarWindowController.swift | 93 ++++++++++++++++++ .../Screens/Remote/NonFocusableWindow.swift | 13 +++ .../Screens/Remote/ScrcpyMirrorView.swift | 68 +++++++++++++- .../Screens/Remote/SideControlBar.swift | 83 ++++++++++++++++ .../Remote/SideControlWindowController.swift | 94 +++++++++++++++++++ 6 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 airsync-mac/Screens/Remote/FloatingNavbar.swift create mode 100644 airsync-mac/Screens/Remote/NavbarWindowController.swift create mode 100644 airsync-mac/Screens/Remote/NonFocusableWindow.swift create mode 100644 airsync-mac/Screens/Remote/SideControlBar.swift create mode 100644 airsync-mac/Screens/Remote/SideControlWindowController.swift diff --git a/airsync-mac/Screens/Remote/FloatingNavbar.swift b/airsync-mac/Screens/Remote/FloatingNavbar.swift new file mode 100644 index 00000000..532ee7db --- /dev/null +++ b/airsync-mac/Screens/Remote/FloatingNavbar.swift @@ -0,0 +1,79 @@ +// +// FloatingNavbar.swift +// AirSync +// +// Created by Sameera Wijerathna on 2026-06-03. +// + +import SwiftUI + +struct FloatingNavbar: View { + @State private var hoveredButton: Int? = nil // 0 for back, 1 for home, 2 for recents + + var body: some View { + buttonsContent + .frame(width: 140, height: 40) + .glassBoxIfAvailable(radius: 20) + } + + var buttonsContent: some View { + HStack(spacing: 16) { + // Back Button + Button(action: { + triggerNavKey(4) + }) { + Image(systemName: "arrowtriangle.left.fill") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + .frame(width: 28, height: 28) + .background(hoveredButton == 0 ? Color.white.opacity(0.15) : Color.clear) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isHovered in + hoveredButton = isHovered ? 0 : nil + } + + // Home Button + Button(action: { + triggerNavKey(3) + }) { + Image(systemName: "circle.fill") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + .frame(width: 28, height: 28) + .background(hoveredButton == 1 ? Color.white.opacity(0.15) : Color.clear) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isHovered in + hoveredButton = isHovered ? 1 : nil + } + + // Recents Button + Button(action: { + triggerNavKey(187) + }) { + Image(systemName: "square.fill") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + .frame(width: 28, height: 28) + .background(hoveredButton == 2 ? Color.white.opacity(0.15) : Color.clear) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isHovered in + hoveredButton = isHovered ? 2 : nil + } + } + } + + private func triggerNavKey(_ keycode: UInt32) { + // Send Key Down + ScrcpyControlClient.shared.sendKeyEvent(action: 0, keycode: keycode) + // Send Key Up after short delay + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { + ScrcpyControlClient.shared.sendKeyEvent(action: 1, keycode: keycode) + } + } +} diff --git a/airsync-mac/Screens/Remote/NavbarWindowController.swift b/airsync-mac/Screens/Remote/NavbarWindowController.swift new file mode 100644 index 00000000..bd38fd34 --- /dev/null +++ b/airsync-mac/Screens/Remote/NavbarWindowController.swift @@ -0,0 +1,93 @@ +// +// NavbarWindowController.swift +// AirSync +// +// Created by Sameera Wijerathna on 2026-06-03. +// + +import AppKit +import SwiftUI + +class NavbarWindowController { + var window: NSWindow? + private var isDismissing = false + + func show(parent: NSWindow, isMirroring: Bool) { + guard !isDismissing else { return } + + if window == nil { + let width: CGFloat = 140 + let height: CGFloat = 40 + + let parentFrame = parent.frame + let x = parentFrame.origin.x + (parentFrame.size.width - width) / 2 + // Start slightly higher (tucked right below parent frame) + let startY = parentFrame.origin.y - height / 2 + let endY = parentFrame.origin.y - height - 12 + + let panel = NonFocusableWindow( + contentRect: NSRect(x: x, y: startY, width: width, height: height), + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .floating + panel.alphaValue = 0.0 + + let hostingView = NSHostingView(rootView: FloatingNavbar()) + hostingView.frame = NSRect(x: 0, y: 0, width: width, height: height) + panel.contentView = hostingView + + self.window = panel + parent.addChildWindow(panel, ordered: .above) + + // Slide down animation + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.25 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().setFrame(NSRect(x: x, y: endY, width: width, height: height), display: true) + panel.animator().alphaValue = 1.0 + }) + } else { + updatePosition(parent: parent) + } + } + + func updatePosition(parent: NSWindow) { + guard let window = window, !isDismissing else { return } + let parentFrame = parent.frame + let width = window.frame.size.width + let height = window.frame.size.height + let x = parentFrame.origin.x + (parentFrame.size.width - width) / 2 + let y = parentFrame.origin.y - height - 12 + window.setFrame(NSRect(x: x, y: y, width: width, height: height), display: true) + } + + func hide() { + guard let panel = window, let parent = panel.parent, !isDismissing else { return } + isDismissing = true + + let parentFrame = parent.frame + let width = panel.frame.size.width + let height = panel.frame.size.height + let x = parentFrame.origin.x + (parentFrame.size.width - width) / 2 + let targetY = parentFrame.origin.y - height / 2 + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + panel.animator().setFrame(NSRect(x: x, y: targetY, width: width, height: height), display: true) + panel.animator().alphaValue = 0.0 + }, completionHandler: { + parent.removeChildWindow(panel) + panel.orderOut(nil) + if self.window == panel { + self.window = nil + } + self.isDismissing = false + }) + } +} diff --git a/airsync-mac/Screens/Remote/NonFocusableWindow.swift b/airsync-mac/Screens/Remote/NonFocusableWindow.swift new file mode 100644 index 00000000..cd7f244a --- /dev/null +++ b/airsync-mac/Screens/Remote/NonFocusableWindow.swift @@ -0,0 +1,13 @@ +// +// NonFocusableWindow.swift +// AirSync +// +// Created by Sameera Wijerathna on 2026-06-03. +// + +import AppKit + +class NonFocusableWindow: NSWindow { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} diff --git a/airsync-mac/Screens/Remote/ScrcpyMirrorView.swift b/airsync-mac/Screens/Remote/ScrcpyMirrorView.swift index 0cf51d0b..fed38008 100644 --- a/airsync-mac/Screens/Remote/ScrcpyMirrorView.swift +++ b/airsync-mac/Screens/Remote/ScrcpyMirrorView.swift @@ -17,6 +17,10 @@ struct ScrcpyMirrorView: View { @State private var errorMessage: String? @State private var isHovering = false @State private var currentWindow: NSWindow? + @State private var isWindowActive = false + @State private var navbarController = NavbarWindowController() + @State private var sideController = SideControlWindowController() + @AppStorage("showMirrorControls") private var showMirrorControls = true private var safeRatio: CGFloat { if streamClient.videoWidth > 0 && streamClient.videoHeight > 0 { @@ -41,6 +45,17 @@ struct ScrcpyMirrorView: View { .animation(.easeInOut(duration: 0.8), value: isStreaming) .ignoresSafeArea() + // Invisible button for Command+B shortcut + Button(action: { + showMirrorControls.toggle() + }) { + Text("") + } + .buttonStyle(.plain) + .keyboardShortcut("b", modifiers: [.command]) + .frame(width: 0, height: 0) + .opacity(0) + VStack(spacing: 0) { // Expanding Header headerView @@ -53,7 +68,9 @@ struct ScrcpyMirrorView: View { MetalVideoView(streamClient: streamClient) .aspectRatio(safeRatio, contentMode: .fit) .cornerRadius(contentCornerRadius) - .padding(isHovering ? 8 : 0) + .padding(.top, isHovering ? 8 : 0) + .padding(.horizontal, isHovering ? 8 : 0) + .padding(.bottom, isHovering ? 20 : 0) .opacity(isStreaming ? 1 : 0) .blur(radius: isStreaming ? 0 : 20) .animation(.spring(response: 0.6, dampingFraction: 0.8), value: isStreaming) @@ -72,6 +89,14 @@ struct ScrcpyMirrorView: View { } .animation(.easeInOut(duration: 1.25), value: isMirroring) .frame(maxWidth: .infinity, maxHeight: .infinity) + + if isHovering { + Text("⌘B to toggle controls") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundColor(.primary.opacity(0.4)) + .padding(.bottom, 8) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } } .background(WindowAccessor(callback: { window in self.currentWindow = window @@ -99,6 +124,25 @@ struct ScrcpyMirrorView: View { AppState.shared.isNativeMirroring = false self.stopMirroring() } + + // Track parent movement/resize to reposition child floating window + NotificationCenter.default.addObserver(forName: NSWindow.didMoveNotification, object: window, queue: .main) { _ in + navbarController.updatePosition(parent: window) + sideController.updatePosition(parent: window) + } + NotificationCenter.default.addObserver(forName: NSWindow.didResizeNotification, object: window, queue: .main) { _ in + navbarController.updatePosition(parent: window) + sideController.updatePosition(parent: window) + } + + // Track focus/active state + self.isWindowActive = window.isKeyWindow + NotificationCenter.default.addObserver(forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main) { _ in + self.isWindowActive = true + } + NotificationCenter.default.addObserver(forName: NSWindow.didResignKeyNotification, object: window, queue: .main) { _ in + self.isWindowActive = false + } })) .ignoresSafeArea() .onAppear { @@ -127,6 +171,13 @@ struct ScrcpyMirrorView: View { } .onChange(of: isMirroring) { _, newValue in if !newValue { isHovering = false } + updateNavbarVisibility() + } + .onChange(of: isWindowActive) { _, _ in + updateNavbarVisibility() + } + .onChange(of: showMirrorControls) { _, _ in + updateNavbarVisibility() } .onChange(of: streamClient.videoWidth) { _, newValue in updateWindowConstraints(width: newValue, height: streamClient.videoHeight) @@ -139,6 +190,8 @@ struct ScrcpyMirrorView: View { } .frame(minWidth: 200, minHeight: 300) .onDisappear { + navbarController.hide() + sideController.hide() stopMirroring() } } @@ -262,4 +315,17 @@ struct ScrcpyMirrorView: View { ScrcpyServerManager.shared.stopServer() isMirroring = false } + + private func updateNavbarVisibility() { + guard let window = currentWindow else { return } + if isWindowActive && isMirroring && showMirrorControls { + navbarController.show(parent: window, isMirroring: isMirroring) + sideController.show(parent: window, isMirroring: isMirroring) + } else { + navbarController.hide() + sideController.hide() + } + } } + + diff --git a/airsync-mac/Screens/Remote/SideControlBar.swift b/airsync-mac/Screens/Remote/SideControlBar.swift new file mode 100644 index 00000000..df83c24a --- /dev/null +++ b/airsync-mac/Screens/Remote/SideControlBar.swift @@ -0,0 +1,83 @@ +// +// SideControlBar.swift +// AirSync +// +// Created by Sameera Wijerathna on 2026-06-03. +// + +import SwiftUI + +struct SideControlBar: View { + @State private var hoveredPower = false + @State private var hoveredVolUp = false + @State private var hoveredVolDown = false + + var body: some View { + VStack(spacing: 12) { + // Power Pill + VStack { + Button(action: { + triggerNavKey(26) // Power keycode + }) { + Image(systemName: "power") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.red) + .frame(width: 28, height: 28) + .background(hoveredPower ? Color.white.opacity(0.15) : Color.clear) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isHovered in + hoveredPower = isHovered + } + } + .frame(width: 40, height: 40) + .glassBoxIfAvailable(radius: 20) + + // Volume Pill + VStack(spacing: 8) { + // Volume Up Button + Button(action: { + triggerNavKey(24) // Volume Up keycode + }) { + Image(systemName: "plus") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .frame(width: 28, height: 28) + .background(hoveredVolUp ? Color.white.opacity(0.15) : Color.clear) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isHovered in + hoveredVolUp = isHovered + } + + // Volume Down Button + Button(action: { + triggerNavKey(25) // Volume Down keycode + }) { + Image(systemName: "minus") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .frame(width: 28, height: 28) + .background(hoveredVolDown ? Color.white.opacity(0.15) : Color.clear) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .onHover { isHovered in + hoveredVolDown = isHovered + } + } + .padding(.vertical, 6) + .frame(width: 40, height: 80) + .glassBoxIfAvailable(radius: 20) + } + } + + private func triggerNavKey(_ keycode: UInt32) { + ScrcpyControlClient.shared.sendKeyEvent(action: 0, keycode: keycode) + DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { + ScrcpyControlClient.shared.sendKeyEvent(action: 1, keycode: keycode) + } + } +} diff --git a/airsync-mac/Screens/Remote/SideControlWindowController.swift b/airsync-mac/Screens/Remote/SideControlWindowController.swift new file mode 100644 index 00000000..bc073a7d --- /dev/null +++ b/airsync-mac/Screens/Remote/SideControlWindowController.swift @@ -0,0 +1,94 @@ +// +// SideControlWindowController.swift +// AirSync +// +// Created by Sameera Wijerathna on 2026-06-03. +// + +import AppKit +import SwiftUI + +class SideControlWindowController { + var window: NSWindow? + private var isDismissing = false + + func show(parent: NSWindow, isMirroring: Bool) { + guard !isDismissing else { return } + + if window == nil { + let width: CGFloat = 40 + let height: CGFloat = 140 + + let parentFrame = parent.frame + let x = parentFrame.origin.x + parentFrame.size.width + 12 + // Start slightly to the left (tucked behind parent frame) + let startX = parentFrame.origin.x + parentFrame.size.width - width / 2 + let targetX = parentFrame.origin.x + parentFrame.size.width + 12 + let y = parentFrame.origin.y + parentFrame.size.height - height - 60 + + let panel = NonFocusableWindow( + contentRect: NSRect(x: startX, y: y, width: width, height: height), + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.level = .floating + panel.alphaValue = 0.0 + + let hostingView = NSHostingView(rootView: SideControlBar()) + hostingView.frame = NSRect(x: 0, y: 0, width: width, height: height) + panel.contentView = hostingView + + self.window = panel + parent.addChildWindow(panel, ordered: .above) + + // Slide right and fade in animation + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.25 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + panel.animator().setFrame(NSRect(x: targetX, y: y, width: width, height: height), display: true) + panel.animator().alphaValue = 1.0 + }) + } else { + updatePosition(parent: parent) + } + } + + func updatePosition(parent: NSWindow) { + guard let window = window, !isDismissing else { return } + let parentFrame = parent.frame + let width = window.frame.size.width + let height = window.frame.size.height + let x = parentFrame.origin.x + parentFrame.size.width + 12 + let y = parentFrame.origin.y + parentFrame.size.height - height - 60 + window.setFrame(NSRect(x: x, y: y, width: width, height: height), display: true) + } + + func hide() { + guard let panel = window, let parent = panel.parent, !isDismissing else { return } + isDismissing = true + + let parentFrame = parent.frame + let width = panel.frame.size.width + let height = panel.frame.size.height + let targetX = parentFrame.origin.x + parentFrame.size.width - width / 2 + let y = parentFrame.origin.y + parentFrame.size.height - height - 60 + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + panel.animator().setFrame(NSRect(x: targetX, y: y, width: width, height: height), display: true) + panel.animator().alphaValue = 0.0 + }, completionHandler: { + parent.removeChildWindow(panel) + panel.orderOut(nil) + if self.window == panel { + self.window = nil + } + self.isDismissing = false + }) + } +} From 51dd80a3797b760649177c92e18c0778fc86d9cb Mon Sep 17 00:00:00 2001 From: sameerasw Date: Wed, 3 Jun 2026 14:27:54 +0530 Subject: [PATCH 9/9] feat: Progress notification support --- airsync-mac/Core/AppleScriptSupport.swift | 8 ++--- .../WebSocket/WebSocketServer+Handlers.swift | 19 ++++++++++- airsync-mac/Model/Notification.swift | 33 ++++++++++++++++++- .../NotificationCardView.swift | 32 ++++++++++++++++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/airsync-mac/Core/AppleScriptSupport.swift b/airsync-mac/Core/AppleScriptSupport.swift index b355262b..6b1344c4 100644 --- a/airsync-mac/Core/AppleScriptSupport.swift +++ b/airsync-mac/Core/AppleScriptSupport.swift @@ -86,7 +86,7 @@ class AirSyncGetNotificationsCommand: NSScriptCommand { "title": notif.title, "body": notif.body, "app": notif.app, - "id": notif.id.uuidString, + "id": notif.id, "package": notif.package, "nid": notif.nid ] @@ -773,8 +773,7 @@ class AirSyncNotificationActionCommand: NSScriptCommand { return "No device connected" } - // Find the notification by ID - guard let notification = appState.notifications.first(where: { $0.id.uuidString == notificationId }) else { + guard let notification = appState.notifications.first(where: { $0.id == notificationId }) else { let errorInfo: [String: Any] = [ "status": "error", "message": "Notification not found with ID: \(notificationId)" @@ -854,8 +853,7 @@ class AirSyncDismissNotificationCommand: NSScriptCommand { return "No device connected" } - // Find the notification by ID - guard let notification = appState.notifications.first(where: { $0.id.uuidString == notificationId }) else { + guard let notification = appState.notifications.first(where: { $0.id == notificationId }) else { let errorInfo: [String: Any] = [ "status": "error", "message": "Notification not found with ID: \(notificationId)" diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift index 9e6c41ce..93d5a2c3 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Handlers.swift @@ -237,7 +237,24 @@ extension WebSocketServer { } } } - let notif = Notification(title: title, body: body, app: app, nid: nid, package: package, priority: priority, actions: actions) + let progress = dict["progress"] as? Int + let progressMax = dict["progressMax"] as? Int + let progressIndeterminate = dict["progressIndeterminate"] as? Bool + let ongoing = dict["ongoing"] as? Bool + + let notif = Notification( + title: title, + body: body, + app: app, + nid: nid, + package: package, + priority: priority, + actions: actions, + progress: progress, + progressMax: progressMax, + progressIndeterminate: progressIndeterminate, + ongoing: ongoing + ) DispatchQueue.main.async { AppState.shared.addNotification(notif) } diff --git a/airsync-mac/Model/Notification.swift b/airsync-mac/Model/Notification.swift index 22ebd87c..0907b4cd 100644 --- a/airsync-mac/Model/Notification.swift +++ b/airsync-mac/Model/Notification.swift @@ -15,7 +15,7 @@ struct NotificationAction: Codable, Hashable, Identifiable { } struct Notification: Codable, Identifiable, Equatable { - let id = UUID() + var id: String { nid } let title: String let body: String let app: String @@ -23,8 +23,39 @@ struct Notification: Codable, Identifiable, Equatable { let package: String let priority: String? let actions: [NotificationAction] + let progress: Int? + let progressMax: Int? + let progressIndeterminate: Bool? + let ongoing: Bool? + + init( + title: String, + body: String, + app: String, + nid: String, + package: String, + priority: String? = nil, + actions: [NotificationAction] = [], + progress: Int? = nil, + progressMax: Int? = nil, + progressIndeterminate: Bool? = nil, + ongoing: Bool? = nil + ) { + self.title = title + self.body = body + self.app = app + self.nid = nid + self.package = package + self.priority = priority + self.actions = actions + self.progress = progress + self.progressMax = progressMax + self.progressIndeterminate = progressIndeterminate + self.ongoing = ongoing + } private enum CodingKeys: String, CodingKey { case title, body, app, nid, package, priority, actions + case progress, progressMax, progressIndeterminate, ongoing } } diff --git a/airsync-mac/Screens/HomeScreen/NotificationView/NotificationCardView.swift b/airsync-mac/Screens/HomeScreen/NotificationView/NotificationCardView.swift index cf26a237..ea573750 100644 --- a/airsync-mac/Screens/HomeScreen/NotificationView/NotificationCardView.swift +++ b/airsync-mac/Screens/HomeScreen/NotificationView/NotificationCardView.swift @@ -30,6 +30,38 @@ struct NotificationCardView: View { Text(notification.body) .font(.body) + if let progressMax = notification.progressMax, progressMax > 0 { + let progress = notification.progress ?? 0 + let isIndeterminate = notification.progressIndeterminate ?? false + + VStack(alignment: .leading, spacing: 4) { + if !isIndeterminate { + HStack { + ProgressView(value: Double(progress), total: Double(progressMax)) + .progressViewStyle(.linear) + .tint(.accentColor) + + Text("\(Int(Double(progress) / Double(progressMax) * 100))%") + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundColor(.secondary) + .frame(width: 32, alignment: .trailing) + } + } else { + ProgressView() + .progressViewStyle(.linear) + .tint(.accentColor) + } + } + .padding(.top, 4) + .padding(.bottom, 2) + } else if notification.progressIndeterminate == true { + ProgressView() + .progressViewStyle(.linear) + .tint(.accentColor) + .padding(.top, 4) + .padding(.bottom, 2) + } + if !notification.actions.isEmpty { HStack(spacing: 8) { ForEach(notification.actions) { action in