diff --git a/airsync-mac/Assets.xcassets/Images/adb-pair-prompt.imageset/Contents.json b/airsync-mac/Assets.xcassets/Images/adb-pair-prompt.imageset/Contents.json new file mode 100644 index 00000000..c5292b28 --- /dev/null +++ b/airsync-mac/Assets.xcassets/Images/adb-pair-prompt.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "adb-pair-prompt.jpeg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/airsync-mac/Assets.xcassets/Images/adb-pair-prompt.imageset/adb-pair-prompt.jpeg b/airsync-mac/Assets.xcassets/Images/adb-pair-prompt.imageset/adb-pair-prompt.jpeg new file mode 100644 index 00000000..1821d48f Binary files /dev/null and b/airsync-mac/Assets.xcassets/Images/adb-pair-prompt.imageset/adb-pair-prompt.jpeg differ diff --git a/airsync-mac/Assets.xcassets/Images/adb-pair.imageset/Contents.json b/airsync-mac/Assets.xcassets/Images/adb-pair.imageset/Contents.json new file mode 100644 index 00000000..4cf5bc1e --- /dev/null +++ b/airsync-mac/Assets.xcassets/Images/adb-pair.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "adb-pair.jpeg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/airsync-mac/Assets.xcassets/Images/adb-pair.imageset/adb-pair.jpeg b/airsync-mac/Assets.xcassets/Images/adb-pair.imageset/adb-pair.jpeg new file mode 100644 index 00000000..4e9f05be Binary files /dev/null and b/airsync-mac/Assets.xcassets/Images/adb-pair.imageset/adb-pair.jpeg differ diff --git a/airsync-mac/Components/Text/MarqueeText.swift b/airsync-mac/Components/Text/MarqueeText.swift new file mode 100644 index 00000000..c0229d29 --- /dev/null +++ b/airsync-mac/Components/Text/MarqueeText.swift @@ -0,0 +1,195 @@ +// +// MarqueeText.swift +// AirSync +// +// Created by Sameera Sandakelum on 2026-05-28. +// + +import SwiftUI +import AppKit + +/// Seamlessly looping marquee text backed by Core Animation (zero CPU per frame). +/// Falls back to a static view when the text fits within `containerWidth`. +struct MarqueeText: NSViewRepresentable { + let text: String + var fontSize: CGFloat = 12 + var fontWeight: NSFont.Weight = .regular + var containerWidth: CGFloat + /// Scroll speed in points per second. + var speed: Double = 40 + /// Gap between the end of one copy and the start of the next. + var gap: CGFloat = 44 + + func makeNSView(context: Context) -> MarqueeNSView { + MarqueeNSView() + } + + func updateNSView(_ nsView: MarqueeNSView, context: Context) { + nsView.update( + text: text, + fontSize: fontSize, + fontWeight: fontWeight, + containerWidth: containerWidth, + speed: speed, + gap: gap + ) + } + + func sizeThatFits(_ proposal: ProposedViewSize, nsView: MarqueeNSView, context: Context) -> CGSize? { + CGSize(width: containerWidth, height: nsView.contentHeight) + } +} + +// MARK: - NSView + +final class MarqueeNSView: NSView { + private(set) var contentHeight: CGFloat = 16 + + private let clipLayer = CALayer() + private let contentLayer = CALayer() + private let textLayer1 = CATextLayer() + private let textLayer2 = CATextLayer() + + // Track last values to avoid unnecessary redraws + private var lastText = "" + private var lastFontSize: CGFloat = -1 + private var lastFontWeight: NSFont.Weight = .regular + private var lastContainerWidth: CGFloat = -1 + private var lastSpeed: Double = -1 + private var lastGap: CGFloat = -1 + + override init(frame: NSRect) { + super.init(frame: frame) + buildLayers() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + buildLayers() + } + + private func buildLayers() { + wantsLayer = true + layer?.masksToBounds = true + + clipLayer.masksToBounds = true + layer?.addSublayer(clipLayer) + + contentLayer.masksToBounds = false + clipLayer.addSublayer(contentLayer) + + let scale = NSScreen.main?.backingScaleFactor ?? 2.0 + for tl in [textLayer1, textLayer2] { + tl.contentsScale = scale + tl.truncationMode = .none + tl.isWrapped = false + tl.alignmentMode = .left + contentLayer.addSublayer(tl) + } + } + + func update(text: String, fontSize: CGFloat, fontWeight: NSFont.Weight, + containerWidth: CGFloat, speed: Double, gap: CGFloat) { + let changed = text != lastText + || fontSize != lastFontSize + || fontWeight != lastFontWeight + || containerWidth != lastContainerWidth + || speed != lastSpeed + || gap != lastGap + guard changed else { return } + + lastText = text + lastFontSize = fontSize + lastFontWeight = fontWeight + lastContainerWidth = containerWidth + lastSpeed = speed + lastGap = gap + + refresh() + } + + // Called on system dark/light mode switch + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + applyTextColor() + } + + override var intrinsicContentSize: NSSize { + NSSize(width: lastContainerWidth, height: contentHeight) + } + + // MARK: - Layout & Animation + + private func refresh() { + let nsFont = NSFont.systemFont(ofSize: lastFontSize, weight: lastFontWeight) + let attrs: [NSAttributedString.Key: Any] = [.font: nsFont] + let measured = (lastText as NSString).size(withAttributes: attrs) + let tw = ceil(measured.width) + let th = ceil(measured.height) + contentHeight = th + + let loopWidth = tw + lastGap + let needsScroll = tw > lastContainerWidth + + CATransaction.begin() + CATransaction.setDisableActions(true) + + let newFrame = NSRect(x: 0, y: 0, width: lastContainerWidth, height: th) + if frame != newFrame { + frame = newFrame + invalidateIntrinsicContentSize() + } + + clipLayer.frame = CGRect(x: 0, y: 0, width: lastContainerWidth, height: th) + + for tl in [textLayer1, textLayer2] { + tl.string = lastText + tl.font = nsFont + tl.fontSize = lastFontSize + } + + if needsScroll { + contentLayer.frame = CGRect(x: 0, y: 0, width: loopWidth * 2, height: th) + textLayer1.frame = CGRect(x: 0, y: 0, width: tw, height: th) + textLayer2.frame = CGRect(x: loopWidth, y: 0, width: tw, height: th) + textLayer2.isHidden = false + } else { + contentLayer.frame = CGRect(x: 0, y: 0, width: tw, height: th) + textLayer1.frame = CGRect(x: 0, y: 0, width: tw, height: th) + textLayer2.isHidden = true + } + + CATransaction.commit() + + applyTextColor() + + // Restart scroll animation + contentLayer.removeAnimation(forKey: "marquee") + guard needsScroll else { return } + + // Reset model position so beginTime fill works correctly + contentLayer.setValue(0, forKeyPath: "transform.translation.x") + + let anim = CABasicAnimation(keyPath: "transform.translation.x") + anim.fromValue = 0 + anim.toValue = -loopWidth + anim.duration = CFTimeInterval(loopWidth) / lastSpeed + anim.repeatCount = .infinity + anim.isRemovedOnCompletion = false + anim.fillMode = .backwards + anim.beginTime = CACurrentMediaTime() + 1.0 // 1s initial pause + contentLayer.add(anim, forKey: "marquee") + } + + private func applyTextColor() { + var resolved: CGColor = NSColor.labelColor.cgColor + effectiveAppearance.performAsCurrentDrawingAppearance { + resolved = NSColor.labelColor.cgColor + } + CATransaction.begin() + CATransaction.setDisableActions(true) + textLayer1.foregroundColor = resolved + textLayer2.foregroundColor = resolved + CATransaction.commit() + } +} diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 03c65d68..7167a03c 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -47,7 +47,8 @@ class AppState: ObservableObject { self.showMenubarDeviceName = UserDefaults.standard.object(forKey: "showMenubarDeviceName") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarDeviceName") let savedMaxLength = UserDefaults.standard.integer(forKey: "menubarTextMaxLength") - self.menubarTextMaxLength = savedMaxLength > 0 ? savedMaxLength : 30 + // Values < 50 are from the old char-count era; migrate them to the new point-width default + self.menubarTextMaxLength = (savedMaxLength >= 50) ? savedMaxLength : 150 self.showMenubarIcon = UserDefaults.standard.object(forKey: "showMenubarIcon") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarIcon") self.menubarBatteryStyle = UserDefaults.standard.string(forKey: "menubarBatteryStyle") ?? "both" @@ -59,6 +60,7 @@ class AppState: ObservableObject { self.showMenubarCallDetails = UserDefaults.standard.bool(forKey: "showMenubarCallDetails") && (!licenseCheck || isPlusLoaded) } self.menubarFontSize = UserDefaults.standard.object(forKey: "menubarFontSize") == nil ? 12.0 : UserDefaults.standard.double(forKey: "menubarFontSize") + self.enableMarquee = UserDefaults.standard.bool(forKey: "enableMarquee") self.menubarUnreadBadgeStyle = UserDefaults.standard.string(forKey: "menubarUnreadBadgeStyle") ?? "badge" self.menubarUnreadBadgeColor = UserDefaults.standard.string(forKey: "menubarUnreadBadgeColor") ?? "accent" self.showMenubarPillStroke = UserDefaults.standard.bool(forKey: "showMenubarPillStroke") @@ -282,6 +284,7 @@ class AppState: ObservableObject { } } @Published var shouldRefreshQR: Bool = false + @Published var isConnectionWeak: Bool = false @Published var webSocketStatus: WebSocketStatus = .stopped @Published var selectedTab: TabIdentifier = .qr @Published var selectedSettingsTab: SettingsTab = .myMac @@ -342,6 +345,12 @@ class AppState: ObservableObject { } } + @Published var enableMarquee: Bool { + didSet { + UserDefaults.standard.set(enableMarquee, forKey: "enableMarquee") + } + } + @Published var showMenubarIcon: Bool { didSet { UserDefaults.standard.set(showMenubarIcon, forKey: "showMenubarIcon") @@ -942,7 +951,7 @@ class AppState: ObservableObject { withAnimation { self.notifications.removeAll { $0.id == notif.id } } - self.removeNotification(notif) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [notif.nid]) } } @@ -964,6 +973,7 @@ class AppState: ObservableObject { // Then locally reset state self.device = nil + self.isConnectionWeak = false self.activeMacIp = nil self.notifications.removeAll() self.status = nil @@ -1056,12 +1066,19 @@ class AppState: ObservableObject { func addNotification(_ notif: Notification) { DispatchQueue.main.async { + var contentChanged = true withAnimation { - self.notifications.insert(notif, at: 0) + if let idx = self.notifications.firstIndex(where: { $0.nid == notif.nid }) { + let old = self.notifications[idx] + contentChanged = (old.title != notif.title || old.body != notif.body || old.actions != notif.actions) + self.notifications[idx] = notif + } else { + self.notifications.insert(notif, at: 0) + } } - // Trigger native macOS notification if not silent + // 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" { + if notif.priority != "silent" && contentChanged { var appIcon: NSImage? = nil if let iconPath = self.androidApps[notif.package]?.iconUrl { appIcon = NSImage(contentsOfFile: iconPath) @@ -1194,17 +1211,22 @@ class AppState: ObservableObject { } func syncWithSystemNotifications() { - UNUserNotificationCenter.current().getDeliveredNotifications { systemNotifs in - let systemNIDs = Set(systemNotifs.map { $0.request.identifier }) + UNUserNotificationCenter.current().getNotificationSettings { settings in + guard settings.authorizationStatus == .authorized else { + return + } + UNUserNotificationCenter.current().getDeliveredNotifications { systemNotifs in + let systemNIDs = Set(systemNotifs.map { $0.request.identifier }) - DispatchQueue.main.async { - // Only sync notifications that were actually posted to system (non-silent) - let currentSystemNIDs = Set(self.notifications.filter { $0.priority != "silent" }.map { $0.nid }) - let removedNIDs = currentSystemNIDs.subtracting(systemNIDs) + DispatchQueue.main.async { + // Only sync notifications that were actually posted to system (non-silent) + let currentSystemNIDs = Set(self.notifications.filter { $0.priority != "silent" }.map { $0.nid }) + let removedNIDs = currentSystemNIDs.subtracting(systemNIDs) - for nid in removedNIDs { - print("[state] (notification) System notification \(nid) was dismissed manually.") - self.removeNotificationById(nid) + for nid in removedNIDs { + print("[state] (notification) System notification \(nid) was dismissed manually.") + self.removeNotificationById(nid) + } } } } diff --git a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift index a2add757..63c48174 100644 --- a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift +++ b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift @@ -71,7 +71,13 @@ class UDPDiscoveryManager: ObservableObject { // MARK: - Smart Triggers private func startMonitoring() { - // 1. System Wake + // 1. System Sleep / Wake + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(handleSystemSleep), + name: NSWorkspace.willSleepNotification, + object: nil + ) NSWorkspace.shared.notificationCenter.addObserver( self, selector: #selector(handleSystemWake), @@ -80,6 +86,10 @@ class UDPDiscoveryManager: ObservableObject { ) // 2. Network Change + setupNetworkPathMonitor() + } + + private func setupNetworkPathMonitor() { networkMonitor = NWPathMonitor() networkMonitor?.pathUpdateHandler = { [weak self] path in guard let self = self else { return } @@ -107,8 +117,22 @@ class UDPDiscoveryManager: ObservableObject { networkMonitor = nil } + @objc private func handleSystemSleep() { + print("[Discovery] System going to sleep – suspending discovery network triggers") + networkChangePendingWork?.cancel() + networkMonitor?.cancel() + networkMonitor = nil + + // Cancel the periodic broadcast timer by temporarily stopping listening + stopListening() + } + @objc private func handleSystemWake() { - print("[Discovery] System wake detected") + print("[Discovery] System wake detected – resuming discovery network triggers") + setupNetworkPathMonitor() + if isListening { + startListening() + } broadcastBurst() } diff --git a/airsync-mac/Core/MenuBarManager.swift b/airsync-mac/Core/MenuBarManager.swift index 8b163d50..293d12bc 100644 --- a/airsync-mac/Core/MenuBarManager.swift +++ b/airsync-mac/Core/MenuBarManager.swift @@ -18,7 +18,7 @@ class MenuBarManager: NSObject { private var cancellables = Set() private var appState = AppState.shared private var temporaryDragLabel: String? - private var hostingView: ClickThroughHostingView? + private var hostingView: ClickThroughHostingView? private let statusButton: MenuBarStatusButton = { let view = MenuBarStatusButton(frame: NSRect(x: 0, y: 0, width: 22, height: 22)) @@ -289,7 +289,7 @@ class MenuBarStatusButton: NSView { } // MARK: - Click-Through Hosting View Subclass -class ClickThroughHostingView: NSHostingView { +class ClickThroughHostingView: NSHostingView { override func hitTest(_ point: NSPoint) -> NSView? { return nil } @@ -316,8 +316,14 @@ struct MenubarStatusView: View { // 2. Status Text / Details if appState.showMenubarText { if let dragLabel = appState.temporaryDragLabel { - Text(dragLabel) - .font(.system(size: appState.menubarFontSize, weight: .medium)) + if appState.enableMarquee { + MarqueeText(text: dragLabel, fontSize: appState.menubarFontSize, fontWeight: .medium, containerWidth: CGFloat(appState.menubarTextMaxLength)) + } else { + Text(dragLabel) + .font(.system(size: appState.menubarFontSize, weight: .medium)) + .lineLimit(1) + .frame(maxWidth: CGFloat(appState.menubarTextMaxLength), alignment: .leading) + } } else { HStack(spacing: 5) { // Left part: Device Name or Music Info @@ -354,9 +360,7 @@ struct MenubarStatusView: View { .layoutPriority(1) } } else if showMusic, let music = appState.status?.music { - let title = music.title.isEmpty ? "Unknown Title" : music.title - let artist = music.artist.isEmpty ? "Unknown Artist" : music.artist - let musicText = truncate(text: "\(title) - \(artist)") + let musicText = "\(music.title) — \(music.artist)" HStack(spacing: 3) { if appState.showMenubarAlbumArt, @@ -375,14 +379,27 @@ struct MenubarStatusView: View { .font(.system(size: appState.menubarFontSize)) .foregroundColor(.accentColor) } - Text(musicText) - .font(.system(size: appState.menubarFontSize)) + + if appState.enableMarquee { + MarqueeText(text: musicText, fontSize: appState.menubarFontSize, containerWidth: CGFloat(appState.menubarTextMaxLength)) + } else { + Text(musicText) + .font(.system(size: appState.menubarFontSize)) + .lineLimit(1) + .frame(maxWidth: CGFloat(appState.menubarTextMaxLength), alignment: .leading) + } } } else if appState.showMenubarDeviceName { let deviceName = appState.device?.name ?? (bleManager.isAuthenticated ? bleManager.connectedDeviceName : nil) ?? "" if !deviceName.isEmpty { - Text(truncate(text: deviceName)) - .font(.system(size: appState.menubarFontSize, weight: .medium)) + if appState.enableMarquee { + MarqueeText(text: deviceName, fontSize: appState.menubarFontSize, fontWeight: .medium, containerWidth: CGFloat(appState.menubarTextMaxLength)) + } else { + Text(deviceName) + .font(.system(size: appState.menubarFontSize, weight: .medium)) + .lineLimit(1) + .frame(maxWidth: CGFloat(appState.menubarTextMaxLength), alignment: .leading) + } } } @@ -529,11 +546,5 @@ struct MenubarStatusView: View { } } - private func truncate(text: String) -> String { - let maxLength = appState.menubarTextMaxLength - if text.count > maxLength { - return String(text.prefix(maxLength - 1)) + "…" - } - return text - } + } diff --git a/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift b/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift index c67d47ce..df9e6124 100644 --- a/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift +++ b/airsync-mac/Core/QuickShare/NearbyConnectionManager.swift @@ -333,6 +333,7 @@ public class NearbyConnectionManager : NSObject, NetServiceDelegate, InboundNear if discoveryRefCount==0{ browser?.cancel() browser=nil + foundServices.removeAll() } } diff --git a/airsync-mac/Core/QuickShare/OutboundNearbyConnection.swift b/airsync-mac/Core/QuickShare/OutboundNearbyConnection.swift index 84d05b4b..4f49f332 100644 --- a/airsync-mac/Core/QuickShare/OutboundNearbyConnection.swift +++ b/airsync-mac/Core/QuickShare/OutboundNearbyConnection.swift @@ -394,6 +394,7 @@ public class OutboundNearbyConnection:NearbyConnection{ transfer.payloadHeader.totalSize=Int64(currentTransfer!.totalBytes) transfer.payloadHeader.isSensitive=false currentTransfer!.currentOffset+=Int64(fileBuffer.count) + let isLastChunk = currentTransfer!.currentOffset == currentTransfer!.totalBytes var wrapper=Location_Nearby_Connections_OfflineFrame() wrapper.version = .v1 @@ -402,7 +403,40 @@ public class OutboundNearbyConnection:NearbyConnection{ wrapper.v1.payloadTransfer=transfer try encryptAndSendOfflineFrame(wrapper, completion: { do{ - try self.sendNextFileChunk() + if isLastChunk { + // Signal end of file (yes, all this for one bit) + var eofTransfer=Location_Nearby_Connections_PayloadTransferFrame() + eofTransfer.packetType = .data + eofTransfer.payloadChunk.offset=self.currentTransfer!.currentOffset + eofTransfer.payloadChunk.flags=1 // <- EOF flag + eofTransfer.payloadHeader.id=self.currentTransfer!.payloadID + eofTransfer.payloadHeader.type = .file + eofTransfer.payloadHeader.totalSize=Int64(self.currentTransfer!.totalBytes) + eofTransfer.payloadHeader.isSensitive=false + + var eofWrapper=Location_Nearby_Connections_OfflineFrame() + eofWrapper.version = .v1 + eofWrapper.v1=Location_Nearby_Connections_V1Frame() + eofWrapper.v1.type = .payloadTransfer + eofWrapper.v1.payloadTransfer=eofTransfer + + #if DEBUG + print("sent data chunk, now sending EOF, current transfer: \(String(describing: self.currentTransfer))") + #endif + try self.encryptAndSendOfflineFrame(eofWrapper, completion: { + do { + #if DEBUG + print("EOF sent successfully, calling sendNextFileChunk for next file or clean disconnect") + #endif + try self.sendNextFileChunk() + } catch { + self.lastError=error + self.protocolError() + } + }) + } else { + try self.sendNextFileChunk() + } }catch{ self.lastError=error self.protocolError() @@ -413,28 +447,6 @@ public class OutboundNearbyConnection:NearbyConnection{ #endif totalBytesSent+=Int64(fileBuffer.count) delegate?.outboundConnection(connection: self, transferProgress: Double(totalBytesSent)/Double(totalBytesToSend)) - - if currentTransfer!.currentOffset==currentTransfer!.totalBytes{ - // Signal end of file (yes, all this for one bit) - var transfer=Location_Nearby_Connections_PayloadTransferFrame() - transfer.packetType = .data - transfer.payloadChunk.offset=currentTransfer!.currentOffset - transfer.payloadChunk.flags=1 // <- this one here - transfer.payloadHeader.id=currentTransfer!.payloadID - transfer.payloadHeader.type = .file - transfer.payloadHeader.totalSize=Int64(currentTransfer!.totalBytes) - transfer.payloadHeader.isSensitive=false - - var wrapper=Location_Nearby_Connections_OfflineFrame() - wrapper.version = .v1 - wrapper.v1=Location_Nearby_Connections_V1Frame() - wrapper.v1.type = .payloadTransfer - wrapper.v1.payloadTransfer=transfer - try encryptAndSendOfflineFrame(wrapper) - #if DEBUG - print("sent EOF, current transfer: \(String(describing: currentTransfer))") - #endif - } } private static func sanitizeFileName(name:String)->String{ diff --git a/airsync-mac/Core/Util/CLI/ADBPairingManager.swift b/airsync-mac/Core/Util/CLI/ADBPairingManager.swift new file mode 100644 index 00000000..208cc89e --- /dev/null +++ b/airsync-mac/Core/Util/CLI/ADBPairingManager.swift @@ -0,0 +1,239 @@ +// +// ADBPairingManager.swift +// airsync-mac +// +// Created by Sameera Sandakelum on 2026-05-27. +// + +import Foundation +import Combine + +class ADBPairingManager: NSObject, ObservableObject, NetServiceDelegate, NetServiceBrowserDelegate { + static let shared = ADBPairingManager() + + @Published var serviceName: String = "" + @Published var password: String = "" + @Published var pairingString: String = "" + + @Published var status: String = "Idle" + @Published var isPairing: Bool = false + + private var pairingService: NetService? + private var pairingBrowser: NetServiceBrowser? + private var connectBrowser: NetServiceBrowser? + + private var discoveredPairingServices: [NetService] = [] + private var discoveredConnectServices: [NetService] = [] + + private var targetIP: String? + private var targetPairingPort: Int? + + func startPairing() { + isPairing = true + status = "Generating pairing credentials..." + + let suffix = (0..<6).map { _ in String(Int.random(in: 0...9)) }.joined() + serviceName = "adb-wireless-\(suffix)" + password = (0..<8).map { _ in String(Int.random(in: 0...9)) }.joined() + pairingString = "WIFI:T:ADB;S:\(serviceName);P:\(password);;" + + status = "Advertising pairing service..." + + // Start mDNS advertisement + pairingService = NetService(domain: "", type: "_adb-tls-pairing._tcp.", name: serviceName, port: 0) + pairingService?.delegate = self + pairingService?.publish() + + // Start browsing for client pairing service + pairingBrowser = NetServiceBrowser() + pairingBrowser?.delegate = self + pairingBrowser?.searchForServices(ofType: "_adb-tls-pairing._tcp.", inDomain: "") + + print("[ADBPairingManager] Started advertising \(serviceName) and browsing for client...") + } + + func stopPairing() { + pairingService?.stop() + pairingService = nil + + pairingBrowser?.stop() + pairingBrowser = nil + + connectBrowser?.stop() + connectBrowser = nil + + discoveredPairingServices.removeAll() + discoveredConnectServices.removeAll() + + isPairing = false + status = "Idle" + targetIP = nil + targetPairingPort = nil + } + + // MARK: - NetServiceDelegate (Advertising) + func netServiceDidPublish(_ sender: NetService) { + print("[ADBPairingManager] Successfully published pairing service: \(sender.name)") + DispatchQueue.main.async { + self.status = "Waiting for device to scan QR code..." + } + } + + func netService(_ sender: NetService, didNotPublish errorDict: [String : NSNumber]) { + print("[ADBPairingManager] Failed to publish pairing service: \(errorDict)") + DispatchQueue.main.async { + self.status = "Failed to start mDNS advertising." + } + } + + // MARK: - NetServiceBrowserDelegate + func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) { + print("[ADBPairingManager] Found service: \(service.name) of type \(service.type)") + + if browser === pairingBrowser { + if service.name.contains(serviceName) || serviceName.contains(service.name) { + print("[ADBPairingManager] Found matching pairing service! Resolving...") + service.delegate = self + service.resolve(withTimeout: 10.0) + discoveredPairingServices.append(service) + DispatchQueue.main.async { + self.status = "Resolving device address..." + } + } + } else if browser === connectBrowser { + service.delegate = self + service.resolve(withTimeout: 10.0) + discoveredConnectServices.append(service) + } + } + + func netServiceDidResolveAddress(_ sender: NetService) { + guard let addresses = sender.addresses, !addresses.isEmpty else { return } + + var ipAddress: String? + var port: Int? + + for address in addresses { + address.withUnsafeBytes { (ptr: UnsafeRawBufferPointer) in + guard let sockaddr = ptr.bindMemory(to: sockaddr.self).baseAddress else { return } + if sockaddr.pointee.sa_family == AF_INET { + guard let sockaddr_in = ptr.bindMemory(to: sockaddr_in.self).baseAddress else { return } + var sin_addr = sockaddr_in.pointee.sin_addr + var ipBuf = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN)) + if inet_ntop(AF_INET, &sin_addr, &ipBuf, socklen_t(INET_ADDRSTRLEN)) != nil { + ipAddress = String(cString: ipBuf) + port = Int(sockaddr_in.pointee.sin_port.bigEndian) + } + } + } + if ipAddress != nil { break } + } + + guard let ip = ipAddress, let p = port else { return } + print("[ADBPairingManager] Resolved service \(sender.name) to \(ip):\(p)") + + if discoveredPairingServices.contains(sender) { + targetIP = ip + targetPairingPort = p + + DispatchQueue.main.async { + self.status = "Found pairing port. Discovering connection port..." + self.startConnectBrowser() + } + } else if discoveredConnectServices.contains(sender) { + if ip == targetIP { + print("[ADBPairingManager] Found debugging port \(p) for IP \(ip)") + connectBrowser?.stop() + connectBrowser = nil + discoveredConnectServices.removeAll() + + let pairingPort = targetPairingPort ?? 0 + let debuggingPort = p + + DispatchQueue.main.async { + self.status = "Device found, pairing..." + } + + executePairAndConnect(ip: ip, pairingPort: pairingPort, debuggingPort: debuggingPort) + } + } + } + + private func startConnectBrowser() { + connectBrowser?.stop() + discoveredConnectServices.removeAll() + + connectBrowser = NetServiceBrowser() + connectBrowser?.delegate = self + connectBrowser?.searchForServices(ofType: "_adb-tls-connect._tcp.", inDomain: "") + } + + private func executePairAndConnect(ip: String, pairingPort: Int, debuggingPort: Int) { + guard let adbPath = ADBConnector.findExecutable(named: "adb", fallbackPaths: ADBConnector.possibleADBPaths) else { + DispatchQueue.main.async { + self.status = "ADB not found. Please install platform-tools." + } + return + } + + let fullPairingAddress = "\(ip):\(pairingPort)" + let fullConnectAddress = "\(ip):\(debuggingPort)" + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + // 1. Run `adb pair : ` + self.runCommand(executable: adbPath, arguments: ["pair", fullPairingAddress, self.password]) { [weak self] pairSuccess, pairOutput in + guard let self = self else { return } + print("[ADBPairingManager] adb pair output: \(pairOutput)") + + if !pairSuccess { + DispatchQueue.main.async { + self.status = "Pairing failed: \(pairOutput)" + } + return + } + + DispatchQueue.main.async { + self.status = "Pairing successful! Connecting..." + } + + // 2. Run `adb connect :` + self.runCommand(executable: adbPath, arguments: ["connect", fullConnectAddress]) { connectSuccess, connectOutput in + DispatchQueue.main.async { + if connectSuccess { + self.status = "Device successfully connected!" + AppState.shared.adbConnected = true + AppState.shared.adbPort = UInt16(debuggingPort) + AppState.shared.adbConnectedIP = ip + AppState.shared.adbConnectionResult = "Connected to \(fullConnectAddress)" + } else { + self.status = "Connection failed: \(connectOutput)" + } + } + } + } + } + } + + private func runCommand(executable: String, arguments: [String], completion: @escaping (Bool, String) -> Void) { + let task = Process() + task.executableURL = URL(fileURLWithPath: executable) + task.arguments = arguments + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + do { + try task.run() + task.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + completion(task.terminationStatus == 0, output) + } catch { + completion(false, error.localizedDescription) + } + } +} diff --git a/airsync-mac/Core/Util/NotificationDelegate.swift b/airsync-mac/Core/Util/NotificationDelegate.swift index b2a51ccb..f26ab625 100644 --- a/airsync-mac/Core/Util/NotificationDelegate.swift +++ b/airsync-mac/Core/Util/NotificationDelegate.swift @@ -10,16 +10,6 @@ import UserNotifications @MainActor class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate { - func userNotificationCenter(_ center: UNUserNotificationCenter, - didRemoveDeliveredNotifications identifiers: [String]) { - for nid in identifiers { - print("[notification-delegate] User dismissed system notification with nid: \(nid)") - DispatchQueue.main.async { - AppState.shared.removeNotificationById(nid) - } - } - } - func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift index 7011528d..975b0d22 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift @@ -55,7 +55,23 @@ extension WebSocketServer { let lastDate = self.lastActivity[sessionId] ?? .distantPast self.lock.unlock() - let isStale = now.timeIntervalSince(lastDate) > timeout + let timeSinceLastActivity = now.timeIntervalSince(lastDate) + let isStale = timeSinceLastActivity > timeout + + let isPrimary = (sessionId == primary) + if isPrimary && !isStale { + let isWeak = timeSinceLastActivity > 15.0 + DispatchQueue.main.async { + if AppState.shared.isConnectionWeak != isWeak { + AppState.shared.isConnectionWeak = isWeak + if isWeak { + print("[websocket] Primary session connection is weak. Time since last activity: \(Int(timeSinceLastActivity))s") + } else { + print("[websocket] Primary session connection recovered.") + } + } + } + } if isStale { let isPrimary = (sessionId == primary) diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index eff27e75..f2930fe1 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -172,6 +172,11 @@ class WebSocketServer: ObservableObject { self.lock.lock() self.lastActivity[ObjectIdentifier(session)] = Date() self.lock.unlock() + DispatchQueue.main.async { + if AppState.shared.isConnectionWeak { + AppState.shared.isConnectionWeak = false + } + } return } @@ -181,6 +186,11 @@ class WebSocketServer: ObservableObject { self.lock.lock() self.lastActivity[ObjectIdentifier(session)] = Date() self.lock.unlock() + DispatchQueue.main.async { + if AppState.shared.isConnectionWeak { + AppState.shared.isConnectionWeak = false + } + } if message.type == .fileChunk || message.type == .fileChunkAck || message.type == .fileTransferComplete || message.type == .fileTransferInit { self.handleMessage(message, session: session) @@ -196,6 +206,11 @@ class WebSocketServer: ObservableObject { self?.lock.lock() self?.lastActivity[ObjectIdentifier(session)] = Date() self?.lock.unlock() + DispatchQueue.main.async { + if AppState.shared.isConnectionWeak { + AppState.shared.isConnectionWeak = false + } + } }, connected: { [weak self] session in guard let self = self else { return } diff --git a/airsync-mac/Localization/af.json b/airsync-mac/Localization/af.json index 0427f383..90d9b014 100644 --- a/airsync-mac/Localization/af.json +++ b/airsync-mac/Localization/af.json @@ -59,7 +59,7 @@ "quickshare.copy": "Kopieer na knipbord", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/ar.json b/airsync-mac/Localization/ar.json index b662570f..a14042eb 100644 --- a/airsync-mac/Localization/ar.json +++ b/airsync-mac/Localization/ar.json @@ -59,7 +59,7 @@ "quickshare.copy": "نسخ إلى الحافظة", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/ca.json b/airsync-mac/Localization/ca.json index 013aa8b1..24c74d57 100644 --- a/airsync-mac/Localization/ca.json +++ b/airsync-mac/Localization/ca.json @@ -59,7 +59,7 @@ "quickshare.copy": "Copia al porta-retalls", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/cs.json b/airsync-mac/Localization/cs.json index e6ceeb51..62b7bfa6 100644 --- a/airsync-mac/Localization/cs.json +++ b/airsync-mac/Localization/cs.json @@ -59,7 +59,7 @@ "quickshare.copy": "Kopírovat do schránky", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/da.json b/airsync-mac/Localization/da.json index cb517ffc..4dc44784 100644 --- a/airsync-mac/Localization/da.json +++ b/airsync-mac/Localization/da.json @@ -59,7 +59,7 @@ "quickshare.copy": "Kopier til udklipsholder", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/de.json b/airsync-mac/Localization/de.json index fcc31905..a318e6b7 100644 --- a/airsync-mac/Localization/de.json +++ b/airsync-mac/Localization/de.json @@ -59,7 +59,7 @@ "quickshare.copy": "In die Zwischenablage kopieren", "settings.menubar.showIcon": "Menüleistensymbol anzeigen", "settings.menubar.showText": "Menüleistentext anzeigen", - "settings.menubar.maxLength": "Maximale Länge", + "settings.menubar.maxLength": "Textbreite", "settings.menubar.showDeviceName": "Gerätenamen anzeigen", "settings.menubar.showBattery": "Batteriesymbol anzeigen", "settings.menubar.showMusic": "Jetzt läuft", diff --git a/airsync-mac/Localization/el.json b/airsync-mac/Localization/el.json index 4dae2391..5cc42b68 100644 --- a/airsync-mac/Localization/el.json +++ b/airsync-mac/Localization/el.json @@ -59,7 +59,7 @@ "quickshare.copy": "Αντιγραφή στο πρόχειρο", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/en.json b/airsync-mac/Localization/en.json index 695ad8f6..3f50c70b 100644 --- a/airsync-mac/Localization/en.json +++ b/airsync-mac/Localization/en.json @@ -59,6 +59,7 @@ "quickshare.done": "Done", "quickshare.sending": "Sending...", "quickshare.receiving": "Receiving...", + "quickshare.connecting": "Connecting...", "quickshare.confirm_pin": "Confirm PIN on your device", "quickshare.finished": "Transfer Finished!", "quickshare.failed": "Transfer Failed", @@ -98,7 +99,9 @@ "quickshare.copy": "Copy to clipboard", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.enableMarquee": "Marquee Text Effect", + "settings.menubar.enableMarquee.info": "Marquee effect might cause increased battery drain.", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", @@ -138,5 +141,10 @@ "menubar.call.accepted": "Accepted", "settings.menubar.notifications.calls": "Calls", "settings.menubar.calls.plusFeatureMessage": "Ongoing call pill details are available in AirSync+", - "settings.autoStartAtLogin": "Auto start at login" + "settings.autoStartAtLogin": "Auto start at login", + "settings.newDevice": "New device?", + "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)." } diff --git a/airsync-mac/Localization/es.json b/airsync-mac/Localization/es.json index 5bc56489..87500d47 100644 --- a/airsync-mac/Localization/es.json +++ b/airsync-mac/Localization/es.json @@ -59,7 +59,7 @@ "quickshare.copy": "Copiar al portapapeles", "settings.menubar.showIcon": "Mostrar icono de barra de menú", "settings.menubar.showText": "Mostrar texto de barra de menú", - "settings.menubar.maxLength": "Longitud máxima", + "settings.menubar.maxLength": "Ancho del texto", "settings.menubar.showDeviceName": "Mostrar nombre del dispositivo", "settings.menubar.showBattery": "Mostrar icono de batería", "settings.menubar.showMusic": "En reproducción", diff --git a/airsync-mac/Localization/fi.json b/airsync-mac/Localization/fi.json index 6d665951..bc843fe0 100644 --- a/airsync-mac/Localization/fi.json +++ b/airsync-mac/Localization/fi.json @@ -59,7 +59,7 @@ "quickshare.copy": "Kopioi leikepöydälle", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/fr.json b/airsync-mac/Localization/fr.json index ab7e2c23..817ea28e 100644 --- a/airsync-mac/Localization/fr.json +++ b/airsync-mac/Localization/fr.json @@ -59,7 +59,7 @@ "quickshare.copy": "Copier dans le presse-papiers", "settings.menubar.showIcon": "Afficher l'icône de la barre de menus", "settings.menubar.showText": "Afficher le texte de la barre de menus", - "settings.menubar.maxLength": "Longueur maximale", + "settings.menubar.maxLength": "Largeur du texte", "settings.menubar.showDeviceName": "Afficher le nom de l'appareil", "settings.menubar.showBattery": "Afficher l'icône de batterie", "settings.menubar.showMusic": "En cours de lecture", diff --git a/airsync-mac/Localization/he.json b/airsync-mac/Localization/he.json index 555e82ea..22c7aed4 100644 --- a/airsync-mac/Localization/he.json +++ b/airsync-mac/Localization/he.json @@ -59,7 +59,7 @@ "quickshare.copy": "העתק ללוח", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/hi.json b/airsync-mac/Localization/hi.json index ccc9e7f9..7aca14a7 100644 --- a/airsync-mac/Localization/hi.json +++ b/airsync-mac/Localization/hi.json @@ -59,7 +59,7 @@ "quickshare.copy": "क्लिपबोर्ड पर कॉपी करें", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/hu.json b/airsync-mac/Localization/hu.json index 686e944c..26c43356 100644 --- a/airsync-mac/Localization/hu.json +++ b/airsync-mac/Localization/hu.json @@ -59,7 +59,7 @@ "quickshare.copy": "Másolás a vágólapra", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/it.json b/airsync-mac/Localization/it.json index 7f7c05d7..e36029b7 100644 --- a/airsync-mac/Localization/it.json +++ b/airsync-mac/Localization/it.json @@ -59,7 +59,7 @@ "quickshare.copy": "Copia negli appunti", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "In riproduzione", diff --git a/airsync-mac/Localization/ja.json b/airsync-mac/Localization/ja.json index cd193f93..c855ab6e 100644 --- a/airsync-mac/Localization/ja.json +++ b/airsync-mac/Localization/ja.json @@ -59,7 +59,7 @@ "quickshare.copy": "クリップボードにコピー", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "再生中", diff --git a/airsync-mac/Localization/ko.json b/airsync-mac/Localization/ko.json index 5cba3bdd..d93f5599 100644 --- a/airsync-mac/Localization/ko.json +++ b/airsync-mac/Localization/ko.json @@ -59,7 +59,7 @@ "quickshare.copy": "클립보드에 복사", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/nl.json b/airsync-mac/Localization/nl.json index 2fd25c28..b23f27d0 100644 --- a/airsync-mac/Localization/nl.json +++ b/airsync-mac/Localization/nl.json @@ -59,7 +59,7 @@ "quickshare.copy": "Kopieer naar klembord", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Nu afspelen", diff --git a/airsync-mac/Localization/no.json b/airsync-mac/Localization/no.json index a85a5e1d..8ef0a250 100644 --- a/airsync-mac/Localization/no.json +++ b/airsync-mac/Localization/no.json @@ -59,7 +59,7 @@ "quickshare.copy": "Kopier til utklippstavle", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/pl.json b/airsync-mac/Localization/pl.json index b3a46474..144f9b09 100644 --- a/airsync-mac/Localization/pl.json +++ b/airsync-mac/Localization/pl.json @@ -59,7 +59,7 @@ "quickshare.copy": "Skopiuj do schowka", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/pt.json b/airsync-mac/Localization/pt.json index dcc9bc53..462258e9 100644 --- a/airsync-mac/Localization/pt.json +++ b/airsync-mac/Localization/pt.json @@ -59,7 +59,7 @@ "quickshare.copy": "Copiar para a área de transferência", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "A reproduzir", diff --git a/airsync-mac/Localization/ro.json b/airsync-mac/Localization/ro.json index 6577a42c..9a35c2b8 100644 --- a/airsync-mac/Localization/ro.json +++ b/airsync-mac/Localization/ro.json @@ -59,7 +59,7 @@ "quickshare.copy": "Copiați în clipboard", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/ru.json b/airsync-mac/Localization/ru.json index 171679a7..a9f43fc7 100644 --- a/airsync-mac/Localization/ru.json +++ b/airsync-mac/Localization/ru.json @@ -59,7 +59,7 @@ "quickshare.copy": "Копировать в буфер обмена", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Сейчас играет", diff --git a/airsync-mac/Localization/si.json b/airsync-mac/Localization/si.json index ec9e8e4d..e3893fa4 100644 --- a/airsync-mac/Localization/si.json +++ b/airsync-mac/Localization/si.json @@ -59,7 +59,7 @@ "quickshare.copy": "ක්ලිප්බෝඩ් එකට කොපි කරන්න", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/sk.json b/airsync-mac/Localization/sk.json index eb3b13d3..d839067d 100644 --- a/airsync-mac/Localization/sk.json +++ b/airsync-mac/Localization/sk.json @@ -59,7 +59,7 @@ "quickshare.copy": "Kopírovať do schránky", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/sr.json b/airsync-mac/Localization/sr.json index 704b8162..50fb6b64 100644 --- a/airsync-mac/Localization/sr.json +++ b/airsync-mac/Localization/sr.json @@ -59,7 +59,7 @@ "quickshare.copy": "Копирај у привремену меморију", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/sv.json b/airsync-mac/Localization/sv.json index 3db5a266..e8b50142 100644 --- a/airsync-mac/Localization/sv.json +++ b/airsync-mac/Localization/sv.json @@ -59,7 +59,7 @@ "quickshare.copy": "Kopiera till urklipp", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/tr.json b/airsync-mac/Localization/tr.json index 6f6bd202..f0674417 100644 --- a/airsync-mac/Localization/tr.json +++ b/airsync-mac/Localization/tr.json @@ -59,7 +59,7 @@ "quickshare.copy": "Panoya kopyala", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/uk.json b/airsync-mac/Localization/uk.json index 22e587bd..b96520b3 100644 --- a/airsync-mac/Localization/uk.json +++ b/airsync-mac/Localization/uk.json @@ -59,7 +59,7 @@ "quickshare.copy": "Копіювати в буфер обміну", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/vi.json b/airsync-mac/Localization/vi.json index 21c21488..71eefa22 100644 --- a/airsync-mac/Localization/vi.json +++ b/airsync-mac/Localization/vi.json @@ -59,7 +59,7 @@ "quickshare.copy": "Sao chép vào khay nhớ tạm", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "Now playing", diff --git a/airsync-mac/Localization/zh-Hans.json b/airsync-mac/Localization/zh-Hans.json index ac2b87bd..71f3dcc5 100644 --- a/airsync-mac/Localization/zh-Hans.json +++ b/airsync-mac/Localization/zh-Hans.json @@ -59,7 +59,7 @@ "quickshare.copy": "复制到剪贴板", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "正在播放", diff --git a/airsync-mac/Localization/zh-Hant.json b/airsync-mac/Localization/zh-Hant.json index 77468415..4f4c7b0c 100644 --- a/airsync-mac/Localization/zh-Hant.json +++ b/airsync-mac/Localization/zh-Hant.json @@ -59,7 +59,7 @@ "quickshare.copy": "複製到剪貼簿", "settings.menubar.showIcon": "Show Menu Bar Icon", "settings.menubar.showText": "Show Menu Bar Text", - "settings.menubar.maxLength": "Max Length", + "settings.menubar.maxLength": "Text Width", "settings.menubar.showDeviceName": "Show Device Name", "settings.menubar.showBattery": "Show Battery Icon", "settings.menubar.showMusic": "正在播放", diff --git a/airsync-mac/Screens/HomeScreen/HomeView.swift b/airsync-mac/Screens/HomeScreen/HomeView.swift index b550cf5f..1483bc61 100644 --- a/airsync-mac/Screens/HomeScreen/HomeView.swift +++ b/airsync-mac/Screens/HomeScreen/HomeView.swift @@ -23,51 +23,63 @@ struct HomeView: View { } var body: some View { - NavigationSplitView(columnVisibility: $columnVisibility) { - ZStack { - if appState.selectedTab == .settings { - SettingsSidebarView() - .transition(.opacity.combined(with: .scale)) - } else if appState.device == nil { - QRScannerSidebarView() - .transition(.opacity.combined(with: .scale)) - } else { - SidebarView() - .transition(.opacity.combined(with: .scale)) + ZStack { + NavigationSplitView(columnVisibility: $columnVisibility) { + ZStack { + if appState.selectedTab == .settings { + SettingsSidebarView() + .transition(.opacity.combined(with: .scale)) + } else if appState.device == nil { + QRScannerSidebarView() + .transition(.opacity.combined(with: .scale)) + } else { + SidebarView() + .transition(.opacity.combined(with: .scale)) + } } + .frame(minWidth: 270) + } detail: { + AppContentView() } - .frame(minWidth: 270) - } detail: { - AppContentView() - } - .navigationTitle("") - .background(.background.opacity(appState.windowOpacity)) - .toolbarBackground( - .clear, - for: .windowToolbar - ) - // Show onboarding sheet when needed - .onAppear { - if needsOnboarding { - showOnboarding = true - appState.isOnboardingActive = true + .navigationTitle("") + .background(.background.opacity(appState.windowOpacity)) + .toolbarBackground( + .clear, + for: .windowToolbar + ) + // Show onboarding sheet when needed + .onAppear { + if needsOnboarding { + showOnboarding = true + appState.isOnboardingActive = true + } + updateSidebarVisibility() } - updateSidebarVisibility() - } - .onChange(of: appState.device) { _, _ in - updateSidebarVisibility() - } - .sheet(isPresented: $showOnboarding) { - OnboardingView() - .frame(minWidth: 640, minHeight: 420) - } - .onChange(of: showOnboarding) { oldValue, newValue in - if !newValue { - appState.isOnboardingActive = false + .onChange(of: appState.device) { _, _ in + updateSidebarVisibility() + } + .sheet(isPresented: $showOnboarding) { + OnboardingView() + .frame(minWidth: 640, minHeight: 420) + } + .onChange(of: showOnboarding) { oldValue, newValue in + if !newValue { + appState.isOnboardingActive = false + } + } + .onChange(of: appState.isOnboardingActive) { oldValue, newValue in + // Force view update to refresh window properties + } + + if appState.isConnectionWeak { + VStack { + Spacer() + ConnectionWeakOverlay(appState: appState) + .padding(.bottom, 20) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .ignoresSafeArea(.keyboard, edges: .bottom) } - } - .onChange(of: appState.isOnboardingActive) { oldValue, newValue in - // Force view update to refresh window properties } } @@ -78,6 +90,39 @@ struct HomeView: View { } } +struct ConnectionWeakOverlay: View { + @ObservedObject var appState: AppState + @State private var pulse = false + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.accentColor) + + Text("Reconnecting to \(appState.device?.name ?? "device")...") + .font(.subheadline) + .fontWeight(.medium) + + + GlassButtonView( + label: "Disconnect", + systemImage: "iphone.slash", + size: .large, + primary: true, + action: { + withAnimation { + appState.disconnectDevice() + } + } + ) + } + .padding(12) + .glassBoxIfAvailable(radius: 24) + .shadow(color: Color.black.opacity(0.2), radius: 10, x: 0, y: 5) + } +} + #Preview { HomeView() } diff --git a/airsync-mac/Screens/HomeScreen/SidebarView.swift b/airsync-mac/Screens/HomeScreen/SidebarView.swift index 061c8880..e1ffbd61 100644 --- a/airsync-mac/Screens/HomeScreen/SidebarView.swift +++ b/airsync-mac/Screens/HomeScreen/SidebarView.swift @@ -23,7 +23,7 @@ struct SidebarView: View { Text(truncated) .font(.title3) } - .padding(6) + .padding(.bottom, 6) if let deviceVersion = appState.device?.version, appState.device?.ipAddress != "BLE", diff --git a/airsync-mac/Screens/MenubarView/MenubarSegments.swift b/airsync-mac/Screens/MenubarView/MenubarSegments.swift index c1976061..60528b18 100644 --- a/airsync-mac/Screens/MenubarView/MenubarSegments.swift +++ b/airsync-mac/Screens/MenubarView/MenubarSegments.swift @@ -84,7 +84,7 @@ struct TopSegmentView: View { } ) - if appState.adbConnected { + if appState.adbConnected && (appState.isPlus || !appState.licenseCheck) { GlassButtonView( label: "Mirror", systemImage: "apps.iphone", diff --git a/airsync-mac/Screens/Settings/ADBPairingSheetView.swift b/airsync-mac/Screens/Settings/ADBPairingSheetView.swift new file mode 100644 index 00000000..f496d83c --- /dev/null +++ b/airsync-mac/Screens/Settings/ADBPairingSheetView.swift @@ -0,0 +1,188 @@ +// +// ADBPairingSheetView.swift +// airsync-mac +// +// Created by Sameera Sandakelum on 2026-05-27. +// + +import SwiftUI +import QRCode +internal import SwiftImageReadWrite + +struct ADBPairingSheetView: View { + @Environment(\.dismiss) private var dismiss + @StateObject private var pairingManager = ADBPairingManager.shared + + @State private var qrImage: CGImage? + @State private var isHowToPairExpanded = false + @State private var isTroubleshootingExpanded = false + + var body: some View { + ZStack { + VisualEffectBlur(material: .hudWindow, blendingMode: .behindWindow) + + VStack { + ScrollView { + VStack(spacing: 20) { + Spacer() + + Text("Pair New ADB Device") + .font(.title2) + .bold() + + if let qrImage = qrImage { + VStack(spacing: 12) { + Image(decorative: qrImage, scale: 1.0) + .resizable() + .interpolation(.none) + .frame(width: 200, height: 200) + .accessibilityLabel("ADB pairing QR Code") + .shadow(radius: 10) + .padding() + .background(Color.black.opacity(0.6), in: RoundedRectangle(cornerRadius: 30)) + + Text(pairingManager.status) + .font(.body) + .foregroundStyle(isErrorStatus ? Color.red : .secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } + } else { + ProgressView("Generating QR Code…") + .frame(width: 200, height: 200) + } + + HStack(spacing: 24) { + // "How to pair?" Button + Button(action: { + isHowToPairExpanded.toggle() + if isHowToPairExpanded { + isTroubleshootingExpanded = false + } + }) { + HStack { + Text(L("settings.pairing.howToPair")) + Image(systemName: isHowToPairExpanded ? "chevron.up" : "chevron.down") + } + } + .buttonStyle(.link) + + // "Troubleshooting" Button + Button(action: { + isTroubleshootingExpanded.toggle() + if isTroubleshootingExpanded { + isHowToPairExpanded = false + } + }) { + HStack { + Text(L("settings.pairing.troubleshooting")) + Image(systemName: isTroubleshootingExpanded ? "chevron.up" : "chevron.down") + } + } + .buttonStyle(.link) + } + + if isHowToPairExpanded { + VStack(alignment: .leading, spacing: 12) { + Text(L("settings.pairing.instructions")) + .font(.body) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: false) + + HStack(spacing: 16) { + Spacer() + Image("adb-pair") + .resizable() + .scaledToFit() + .frame(maxWidth: 240, maxHeight: 180) + .cornerRadius(12) + .shadow(radius: 4) + + Image("adb-pair-prompt") + .resizable() + .scaledToFit() + .frame(maxWidth: 240, maxHeight: 180) + .cornerRadius(12) + .shadow(radius: 4) + Spacer() + } + .padding(.vertical, 8) + } + .padding() + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if isTroubleshootingExpanded { + VStack(alignment: .leading, spacing: 12) { + Text(L("settings.pairing.troubleshooting.text")) + .font(.body) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: false) + } + .padding() + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Spacer() + } + .padding() + } + + Divider() + + HStack { + Spacer() + + GlassButtonView( + label: "Close", + systemImage: "xmark.circle", + action: { + pairingManager.stopPairing() + dismiss() + } + ) + .keyboardShortcut(.cancelAction) + } + .padding([.horizontal, .bottom]) + } + } + .frame(width: 600, height: 500) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .shadow(radius: 20) + .onAppear { + pairingManager.startPairing() + generateQRAsync() + } + .onChange(of: pairingManager.pairingString) { _, _ in + generateQRAsync() + } + .onChange(of: pairingManager.status) { _, newStatus in + if newStatus == "Device successfully connected!" { + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + pairingManager.stopPairing() + dismiss() + } + } + } + } + + private var isErrorStatus: Bool { + let status = pairingManager.status.lowercased() + return status.contains("failed") || status.contains("error") + } + + private func generateQRAsync() { + guard !pairingManager.pairingString.isEmpty else { return } + Task { + if let cgImage = await QRCodeGenerator.generateQRCode(for: pairingManager.pairingString) { + DispatchQueue.main.async { + self.qrImage = cgImage + } + } + } + } +} diff --git a/airsync-mac/Screens/Settings/MenubarSettingsView.swift b/airsync-mac/Screens/Settings/MenubarSettingsView.swift index 6e8e3188..b46fa5fe 100644 --- a/airsync-mac/Screens/Settings/MenubarSettingsView.swift +++ b/airsync-mac/Screens/Settings/MenubarSettingsView.swift @@ -4,6 +4,7 @@ struct MenubarSettingsView: View { @ObservedObject var appState = AppState.shared @State private var showingPlusPopover = false @State private var plusPopoverMessage = "" + @State private var showMarqueeInfo = false var body: some View { ScrollView { @@ -51,11 +52,34 @@ struct MenubarSettingsView: View { get: { Double(appState.menubarTextMaxLength) }, set: { appState.menubarTextMaxLength = Int($0) } ), - in: 10...80, - step: 5 + in: 50...300, + step: 10 ) .frame(width: 150) .controlSize(.small) + + 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) + } + .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 { @@ -215,6 +239,7 @@ struct MenubarSettingsView: View { } .padding() .animation(.spring(), value: appState.showMenubarText) + .animation(.spring(), value: appState.enableMarquee) .animation(.spring(), value: appState.showMenubarIcon) .animation(.spring(), value: appState.menubarBatteryStyle) .animation(.spring(), value: appState.showMenubarMusicIcon) diff --git a/airsync-mac/Screens/Settings/SyncSettingsView.swift b/airsync-mac/Screens/Settings/SyncSettingsView.swift index 55d3dd29..306b0dd4 100644 --- a/airsync-mac/Screens/Settings/SyncSettingsView.swift +++ b/airsync-mac/Screens/Settings/SyncSettingsView.swift @@ -15,6 +15,7 @@ struct SyncSettingsView: View { @AppStorage("showInControlCenter") private var showInControlCenter = false @State private var showControlCenterInfo = false + @State private var showPairingSheet = false // State for notification permissions @State private var notificationsGranted = false @@ -24,7 +25,18 @@ struct SyncSettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 20) { // 1. Wireless / Wired ADB - headerSection(title: "Connection & ADB", icon: "bolt.horizontal.circle") + HStack { + headerSection(title: "Connection & ADB", icon: "bolt.horizontal.circle") + Spacer() + GlassButtonView( + label: L("settings.newDevice"), + systemImage: "qrcode", + action: { + showPairingSheet = true + } + ) + .padding(.trailing, 8) + } VStack(spacing: 12) { ZStack { HStack { @@ -261,10 +273,12 @@ struct SyncSettingsView: View { } } .padding() - .glassBoxIfAvailable(radius: 18) .sheet(isPresented: $showRemoteSheet) { RemotePermissionView() } + .sheet(isPresented: $showPairingSheet) { + ADBPairingSheetView() + } } .padding() }