diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 969976ea..8641f3a9 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -48,6 +48,9 @@ class AppState: ObservableObject { let savedFallbackToMdns = UserDefaults.standard.object(forKey: "fallbackToMdns") self.fallbackToMdns = savedFallbackToMdns == nil ? true : UserDefaults.standard.bool(forKey: "fallbackToMdns") + let savedShowInMenubar = UserDefaults.standard.object(forKey: "showInMenubar") + self.showInMenubar = savedShowInMenubar == nil ? true : UserDefaults.standard.bool(forKey: "showInMenubar") + self.showMenubarText = UserDefaults.standard.bool(forKey: "showMenubarText") self.showMenubarDeviceName = UserDefaults.standard.object(forKey: "showMenubarDeviceName") == nil ? true : UserDefaults.standard.bool(forKey: "showMenubarDeviceName") @@ -541,6 +544,8 @@ class AppState: ObservableObject { } } + @Published var lastLicenseCheckFailureReason: String? = nil + @Published var adbPort: UInt16 { didSet { UserDefaults.standard.set(adbPort, forKey: "adbPort") @@ -590,9 +595,18 @@ class AppState: ObservableObject { } } + @Published var showInMenubar: Bool { + didSet { + UserDefaults.standard.set(showInMenubar, forKey: "showInMenubar") + } + } + @Published var hideDockIcon: Bool { didSet { UserDefaults.standard.set(hideDockIcon, forKey: "hideDockIcon") + if hideDockIcon { + showInMenubar = true + } updateDockIconVisibility() } } diff --git a/airsync-mac/Core/BLE/BLECentralManager.swift b/airsync-mac/Core/BLE/BLECentralManager.swift index 973f9010..0574f740 100644 --- a/airsync-mac/Core/BLE/BLECentralManager.swift +++ b/airsync-mac/Core/BLE/BLECentralManager.swift @@ -162,7 +162,8 @@ class BLECentralManager: NSObject, ObservableObject { func write(characteristicUUID: CBUUID, data: Data) { resetWatchdog() guard let peripheral = discoveredPeripheral, let char = characteristics[characteristicUUID] else { return } - peripheral.writeValue(data, for: char, type: .withoutResponse) + let writeType: CBCharacteristicWriteType = char.properties.contains(.write) ? .withResponse : .withoutResponse + peripheral.writeValue(data, for: char, type: writeType) } func writeChunked(characteristicUUID: CBUUID, payload: String) { @@ -210,7 +211,6 @@ class BLECentralManager: NSObject, ObservableObject { scanTimer = nil connectingDeviceUUID = uuidStr - connectionStatus = .scanning centralManager.connect(peripheral, options: [ CBConnectPeripheralOptionNotifyOnDisconnectionKey: true ]) @@ -234,8 +234,8 @@ class BLECentralManager: NSObject, ObservableObject { private func resetWatchdog() { DispatchQueue.main.async { self.watchdogTimer?.invalidate() - self.watchdogTimer = Timer.scheduledTimer(withTimeInterval: 25.0, repeats: false) { [weak self] _ in - print("[BLE] Heartbeat timeout (25s), disconnecting...") + self.watchdogTimer = Timer.scheduledTimer(withTimeInterval: 120.0, repeats: false) { [weak self] _ in + print("[BLE] Heartbeat timeout (120s), disconnecting...") self?.disconnect() } } @@ -270,6 +270,9 @@ extension BLECentralManager: CBCentralManagerDelegate { // Auto connect if enabled and not manually disconnected if AppState.shared.isBLEAutoConnectEnabled && !isManuallyDisconnected { + guard connectionStatus == .disconnected || connectionStatus == .scanning else { return } + guard discoveredPeripheral == nil else { return } + let isWifiConnected = AppState.shared.device != nil && AppState.shared.device?.ipAddress != "BLE" && AppState.shared.device?.ipAddress != "Bluetooth LE" if isWifiConnected { print("[BLE] Regular Wi-Fi connection is active — skipping auto-connect to BLE") @@ -456,9 +459,9 @@ extension BLECentralManager: CBPeripheralDelegate { // Immediately notify Android of Mac status WebSocketServer.shared.sendMacStatusOverBLE() - // Also trigger a full fetch (which includes media info) + // Also trigger a status update (which includes current media info) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - MacInfoSyncManager.shared.fetch() + MacInfoSyncManager.shared.forceRefresh() } } else { print("[BLE] Auth Failed!") diff --git a/airsync-mac/Core/MenuBarManager.swift b/airsync-mac/Core/MenuBarManager.swift index 1c2b83e3..a3b41a05 100644 --- a/airsync-mac/Core/MenuBarManager.swift +++ b/airsync-mac/Core/MenuBarManager.swift @@ -82,6 +82,7 @@ class MenuBarManager: NSObject { appState.$device.map { _ in () }.eraseToAnyPublisher(), appState.$notifications.map { _ in () }.eraseToAnyPublisher(), appState.$status.map { _ in () }.eraseToAnyPublisher(), + appState.$showInMenubar.map { _ in () }.eraseToAnyPublisher(), appState.$showMenubarText.map { _ in () }.eraseToAnyPublisher(), appState.$showingQuickShareTransfer.map { _ in () }.eraseToAnyPublisher(), appState.$showMenubarIcon.map { _ in () }.eraseToAnyPublisher(), @@ -121,13 +122,17 @@ class MenuBarManager: NSObject { } func updateStatusItem() { - guard let button = statusItem?.button, let hostingView = hostingView else { return } + guard let statusItem = statusItem else { return } + + let isVisible = appState.showInMenubar + statusItem.isVisible = isVisible + guard isVisible, let button = statusItem.button, let hostingView = hostingView else { return } button.image = nil button.title = "" let fittingSize = hostingView.fittingSize - statusItem?.length = max(22, fittingSize.width) + statusItem.length = max(22, fittingSize.width) } func showDragLabel(_ label: String) { diff --git a/airsync-mac/Core/Util/CLI/NowPlayingCLI.swift b/airsync-mac/Core/Util/CLI/NowPlayingCLI.swift index cda5bf4a..d5ee7028 100644 --- a/airsync-mac/Core/Util/CLI/NowPlayingCLI.swift +++ b/airsync-mac/Core/Util/CLI/NowPlayingCLI.swift @@ -49,74 +49,163 @@ class NowPlayingCLI { return nil } - func fetchNowPlaying(completion: @escaping (NowPlayingInfo?) -> Void) { + private var streamProcess: Process? + private var streamPipe: Pipe? + private var onUpdateHandler: ((NowPlayingInfo?) -> Void)? + private var isStreamingActive = false + private var retryCount = 0 + private let maxRetries = 10 + private var lineBuffer = Data() + + func startStreaming(onUpdate: @escaping (NowPlayingInfo?) -> Void) { + self.onUpdateHandler = onUpdate + self.isStreamingActive = true + self.retryCount = 0 + launchStreamProcess() + } + + func stopStreaming() { + self.isStreamingActive = false + self.retryCount = 0 + self.onUpdateHandler = nil + terminateStreamProcess() + } + + func resetRetryCount() { + self.retryCount = 0 + } + + private func terminateStreamProcess() { + if let process = streamProcess { + process.terminationHandler = nil + if process.isRunning { + process.terminate() + } + } + streamPipe?.fileHandleForReading.readabilityHandler = nil + streamProcess = nil + streamPipe = nil + lineBuffer.removeAll() + } + + private func launchStreamProcess() { + guard isStreamingActive else { return } + guard let binPath = resolveBinaryPath() else { - // media-control not available; gracefully return nil - print("[now-playing] media-control binary not found. Install with: brew install media-control") - completion(Optional.none) + print("[now-playing] media-control binary not found. Cannot stream updates.") + DispatchQueue.main.async { [weak self] in + self?.onUpdateHandler?(nil) + } return } + terminateStreamProcess() + let process = Process() process.executableURL = URL(fileURLWithPath: binPath) - process.arguments = ["get"] + process.arguments = ["stream", "--no-diff"] let pipe = Pipe() process.standardOutput = pipe - process.standardError = pipe - - let handle = pipe.fileHandleForReading + process.standardError = FileHandle.nullDevice - var buffer = Data() + let readHandle = pipe.fileHandleForReading - handle.readabilityHandler = { fileHandle in - let data = fileHandle.availableData + readHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData guard !data.isEmpty else { return } - buffer.append(data) + self?.handleStreamData(data) } - process.terminationHandler = { _ in - handle.readabilityHandler = nil - guard !buffer.isEmpty else { - DispatchQueue.main.async { completion(Optional.none) } - return - } - - // Try decoding the full JSON at once - if let rawString = String(data: buffer, encoding: .utf8) { - let trimmed = rawString.trimmingCharacters(in: .whitespacesAndNewlines) -// print("[now-playing] Full media-control output:", trimmed) // debug + process.terminationHandler = { [weak self] proc in + guard let self = self else { return } + readHandle.readabilityHandler = nil - // If media-control returns literal "null", treat as no media - if trimmed.isEmpty || trimmed.lowercased() == "null" { - DispatchQueue.main.async { completion(nil) } - return - } + DispatchQueue.main.async { + guard self.isStreamingActive else { return } - do { - let obj = try JSONSerialization.jsonObject(with: Data(trimmed.utf8)) - if let dict = obj as? [String: Any] { - var info = NowPlayingInfo() - info.updateFromPayload(dict) - DispatchQueue.main.async { completion(info) } - } else { - // Not a dictionary (could be null/array) -> no media info - DispatchQueue.main.async { completion(nil) } + if self.retryCount < self.maxRetries { + self.retryCount += 1 + print("[now-playing] media-control stream terminated unexpectedly. Retry \(self.retryCount)/\(self.maxRetries)") + let delay = min(Double(self.retryCount) * 0.5, 3.0) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + self.launchStreamProcess() } - } catch { - print("[now-playing] JSON parse error:", error) - DispatchQueue.main.async { completion(nil) } + } else { + print("[now-playing] media-control stream reached max retries (\(self.maxRetries)). Stopping retry attempts until reset.") + self.onUpdateHandler?(nil) } - } else { - DispatchQueue.main.async { completion(nil) } } } + self.streamProcess = process + self.streamPipe = pipe + do { try process.run() + print("[now-playing] Successfully launched media-control stream --no-diff") } catch { - print("[now-playing] Failed to run media-control get:", error) - completion(Optional.none) + print("[now-playing] Failed to launch media-control stream:", error) + process.terminationHandler = nil + if retryCount < maxRetries { + retryCount += 1 + let delay = min(Double(retryCount) * 0.5, 3.0) + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.launchStreamProcess() + } + } + } + } + + private func handleStreamData(_ data: Data) { + lineBuffer.append(data) + + while let newlineIndex = lineBuffer.firstIndex(of: UInt8(ascii: "\n")) { + let lineData = lineBuffer.subdata(in: 0.. Bool { + let trimmedKey = key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedKey.isEmpty else { + print("[gumroad] License check failed: Empty license key provided") + throw LicenseCheckError.invalidKey("License key cannot be empty.") + } // Select product id based on chosen plan let selectedPlan = UserDefaults.standard.licensePlanType @@ -25,12 +52,16 @@ class Gumroad { let oneTimeProductID = "3HkBPf4ovp7KiVISJS6N5A==" let productID = (selectedPlan == .oneTime) ? oneTimeProductID : membershipProductID let url = URL(string: "https://api.gumroad.com/v2/licenses/verify")! + + let maskedKey = trimmedKey.count > 8 ? "\(trimmedKey.prefix(4))....\(trimmedKey.suffix(4))" : "****" + print("[gumroad] Verifying license key '\(maskedKey)' | Plan: \(selectedPlan.displayName) | Product ID: \(productID) | Is New Registration: \(isNewRegistration)") + var request = URLRequest(url: url) request.httpMethod = "POST" let bodyComponents: [String: String] = [ "product_id": productID, - "license_key": key, + "license_key": trimmedKey, "increment_uses_count": isNewRegistration ? "true" : "false" ] @@ -46,44 +77,74 @@ class Gumroad { do { (data, response) = try await URLSession.shared.data(for: request) } catch { - // Transport / connectivity error + print("[gumroad] Network error during HTTP request: \(error.localizedDescription)") throw LicenseCheckError.network(error) } guard let httpResponse = response as? HTTPURLResponse else { - throw LicenseCheckError.server("Invalid HTTP response") + print("[gumroad] Server error: Invalid HTTP response object") + throw LicenseCheckError.server("Invalid HTTP response from server.") } - // Treat 404 as an invalid license (not a network error) + print("[gumroad] Received HTTP \(httpResponse.statusCode) response from Gumroad API") + + // Treat 404 as an invalid license / plan mismatch if httpResponse.statusCode == 404 { + let errorMsg = "License key not found for \(selectedPlan.displayName) plan. If you purchased a different tier, try changing the plan picker." + print("[gumroad] License check failed (HTTP 404): Key '\(maskedKey)' not found for product ID '\(productID)'. \(errorMsg)") if save { AppState.shared.isPlus = false AppState.shared.licenseDetails = nil + AppState.shared.lastLicenseCheckFailureReason = errorMsg } - return false + throw LicenseCheckError.planMismatch(errorMsg) } // Accept only 2xx here; other codes are server-ish problems guard (200...299).contains(httpResponse.statusCode) else { - throw LicenseCheckError.server("HTTP \(httpResponse.statusCode)") + let errorMsg = "Gumroad server error (HTTP \(httpResponse.statusCode)). Please try again later." + print("[gumroad] License check failed: Server status code \(httpResponse.statusCode)") + throw LicenseCheckError.server(errorMsg) } // Parse JSON guard - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let success = json["success"] as? Bool, - let purchase = json["purchase"] as? [String: Any] + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - throw LicenseCheckError.server("Malformed JSON") + print("[gumroad] License check failed: Failed to parse JSON response") + throw LicenseCheckError.server("Malformed JSON response from Gumroad.") } + let success = json["success"] as? Bool ?? false + let apiMessage = json["message"] as? String + // If Gumroad says not success => invalid license - guard success else { + guard success, let purchase = json["purchase"] as? [String: Any] else { + let detailMessage = apiMessage ?? "Invalid license key or unverified purchase." + let errorMsg = "Gumroad: \(detailMessage)" + print("[gumroad] License check failed (success=false): \(detailMessage)") if save { AppState.shared.isPlus = false AppState.shared.licenseDetails = nil + AppState.shared.lastLicenseCheckFailureReason = errorMsg } - return false + throw LicenseCheckError.invalidKey(errorMsg) + } + + // Refunded, disputed, or chargebacked check + let refunded = purchase["refunded"] as? Bool ?? false + let disputed = purchase["disputed"] as? Bool ?? false + let chargebacked = purchase["chargebacked"] as? Bool ?? false + if refunded || disputed || chargebacked { + let statusStr = refunded ? "refunded" : (disputed ? "disputed" : "chargebacked") + let errorMsg = "This license key has been \(statusStr)." + print("[gumroad] License check failed: Key '\(maskedKey)' was \(statusStr)") + if save { + AppState.shared.isPlus = false + AppState.shared.licenseDetails = nil + AppState.shared.lastLicenseCheckFailureReason = errorMsg + } + throw LicenseCheckError.refunded(errorMsg) } // Subscription-only fields — for one-time purchase these may be nil/empty. @@ -93,15 +154,23 @@ class Gumroad { // Membership plan must be active; otherwise invalid if selectedPlan == .membership { - if [cancelledAt, endedAt, failedAt].contains(where: { dateStr in - if let s = dateStr, !s.isEmpty { return true } - return false - }) { + let isCancelled = cancelledAt != nil && !cancelledAt!.isEmpty + let isEnded = endedAt != nil && !endedAt!.isEmpty + let isFailed = failedAt != nil && !failedAt!.isEmpty + + if isCancelled || isEnded || isFailed { + var reason = "Subscription inactive." + if isEnded { reason = "Subscription ended on \(endedAt!)." } + else if isFailed { reason = "Subscription payment failed on \(failedAt!)." } + else if isCancelled { reason = "Subscription was cancelled on \(cancelledAt!)." } + + print("[gumroad] License check failed: Membership inactive — \(reason)") if save { AppState.shared.isPlus = false AppState.shared.licenseDetails = nil + AppState.shared.lastLicenseCheckFailureReason = reason } - return false + throw LicenseCheckError.subscriptionInactive(reason) } } @@ -109,32 +178,41 @@ class Gumroad { let currentUsesCount = json["uses"] as? Int ?? 0 let previousUsesCount = AppState.shared.licenseDetails?.usesCount ?? currentUsesCount if (currentUsesCount - previousUsesCount) > 3 { + let errorMsg = "License usage limit reached (\(currentUsesCount) total activations)." + print("[gumroad] License check failed: Usage limit exceeded (current uses: \(currentUsesCount), previous: \(previousUsesCount))") if save { AppState.shared.isPlus = false AppState.shared.licenseDetails = nil + AppState.shared.lastLicenseCheckFailureReason = errorMsg } - return false + throw LicenseCheckError.usesExceeded(errorMsg) } // Valid license + let email = purchase["email"] as? String ?? "unknown" + let productName = purchase["product_name"] as? String ?? "unknown" + let orderNumber = purchase["order_number"] as? Int ?? 0 + print("[gumroad] License verified successfully! Email: \(email), Product: \(productName), Order #: \(orderNumber), Uses: \(currentUsesCount)") + if save { AppState.shared.isPlus = true + AppState.shared.lastLicenseCheckFailureReason = nil let details = LicenseDetails( - key: key, - email: purchase["email"] as? String ?? "unknown", - productName: purchase["product_name"] as? String ?? "unknown", - orderNumber: purchase["order_number"] as? Int ?? 0, + key: trimmedKey, + email: email, + productName: productName, + orderNumber: orderNumber, purchaserID: purchase["purchaser_id"] as? String ?? "", - usesCount: json["uses"] as? Int ?? 0, + usesCount: currentUsesCount, price: purchase["price"] as? Int ?? 0, currency: purchase["currency"] as? String ?? "usd", saleTimestamp: purchase["sale_timestamp"] as? String ?? "", subscriptionCancelledAt: cancelledAt, subscriptionEndedAt: endedAt, subscriptionFailedAt: failedAt, - refunded: purchase["refunded"] as? Bool ?? false, - disputed: purchase["disputed"] as? Bool ?? false, - chargebacked: purchase["chargebacked"] as? Bool ?? false + refunded: refunded, + disputed: disputed, + chargebacked: chargebacked ) AppState.shared.licenseDetails = details } @@ -143,7 +221,9 @@ class Gumroad { } func clearLicenseDetails() { + print("[gumroad] Clearing saved license details") AppState.shared.licenseDetails = nil + AppState.shared.lastLicenseCheckFailureReason = nil UserDefaults.standard.removeObject(forKey: "licenseDetailsKey") UserDefaults.standard.consecutiveLicenseFailCount = 0 UserDefaults.standard.lastLicenseSuccessfulCheckDate = nil @@ -153,6 +233,7 @@ class Gumroad { let failCount = UserDefaults.standard.consecutiveLicenseFailCount + 1 UserDefaults.standard.consecutiveLicenseFailCount = failCount + print("[gumroad] License check fail count incremented to \(failCount)/3") if failCount >= 3 { Gumroad().clearLicenseDetails() print("[gumroad] License check failed \(failCount) times — license removed") @@ -160,8 +241,10 @@ class Gumroad { } func performUnregisterWithAlert(reason: String) { + print("[gumroad] Unregistering license with alert: \(reason)") // Clear local license and disable Plus appState.isPlus = false + AppState.shared.lastLicenseCheckFailureReason = reason Gumroad().clearLicenseDetails() UserDefaults.standard.consecutiveNetworkFailureDays = 0 UserDefaults.standard.set(nil, forKey: "lastNetworkFailureDay") @@ -185,20 +268,20 @@ class Gumroad { @MainActor func checkLicense() async { - // Always record that we attempted today (used to prevent double-counting network failures in one day) let now = Date() let calendar = Calendar.current - // If no stored key, behave as before guard let key = appState.licenseDetails?.key, !key.isEmpty else { + print("[gumroad] No saved license key found for scheduled check.") if !TrialManager.shared.isTrialActive { appState.isPlus = false } - Gumroad().incrementInvalidLicenseFailCount() // treat as invalid (no key) + Gumroad().incrementInvalidLicenseFailCount() UserDefaults.standard.lastLicenseCheckDate = now return } + print("[gumroad] Running routine license check...") do { let valid = try await Gumroad().checkLicenseKeyValidity( key: key, @@ -209,64 +292,56 @@ class Gumroad { UserDefaults.standard.lastLicenseCheckDate = now if valid { - // Successful validation today UserDefaults.standard.consecutiveNetworkFailureDays = 0 UserDefaults.standard.consecutiveLicenseFailCount = 0 UserDefaults.standard.lastLicenseSuccessfulCheckDate = now appState.isPlus = true - print("[gumroad] License valid — daily success recorded.") + appState.lastLicenseCheckFailureReason = nil + print("[gumroad] Routine license check passed — daily success recorded.") } else { - // Invalid/expired/cancelled/license-limit — disable immediately if !TrialManager.shared.isTrialActive { appState.isPlus = false } Gumroad().incrementInvalidLicenseFailCount() - // Reset network failure streak because this is not a network failure UserDefaults.standard.consecutiveNetworkFailureDays = 0 - print("[gumroad] License invalid or expired — disabled Plus (unless trial active).") + print("[gumroad] Routine license check failed: Key invalid or expired — disabled Plus.") } } catch let error as LicenseCheckError { - // Network/server failure: do not disable Plus today UserDefaults.standard.lastLicenseCheckDate = now - // Only increment once per calendar day - var consecutiveDays = UserDefaults.standard.consecutiveNetworkFailureDays - if UserDefaults.standard.lastLicenseSuccessfulCheckDate != nil { - // last successful check date exists; if it's today we already validated; but we are here only if not successful today - // we still want to count by day against previous attempt date - } - // Compare with the last attempt day (not last success) to avoid double-counting same day - if UserDefaults.standard.lastLicenseCheckDate != nil { - // We just set it to 'now'; we need the previous value to compare. To avoid this race, - // check by reading previous date first before setting in future refactors. - // For current code path, guard by storing previous date before the call if needed. - } - // Simpler: increment if last success date is not today OR there was no success date recently - // Also ensure we don't increment more than once a day by comparing with a stored "last network failure day" if needed. - // For simplicity, we’ll increment if not already incremented today: - let lastNetworkDay = UserDefaults.standard.object(forKey: "lastNetworkFailureDay") as? Date - if lastNetworkDay == nil || !calendar.isDate(lastNetworkDay!, inSameDayAs: now) { - consecutiveDays += 1 - UserDefaults.standard.set(now, forKey: "lastNetworkFailureDay") - } - UserDefaults.standard.consecutiveNetworkFailureDays = consecutiveDays + switch error { + case .network(let sysErr): + print("[gumroad] Network error during routine check: \(sysErr.localizedDescription)") + let lastNetworkDay = UserDefaults.standard.object(forKey: "lastNetworkFailureDay") as? Date + var consecutiveDays = UserDefaults.standard.consecutiveNetworkFailureDays + if lastNetworkDay == nil || !calendar.isDate(lastNetworkDay!, inSameDayAs: now) { + consecutiveDays += 1 + UserDefaults.standard.set(now, forKey: "lastNetworkFailureDay") + } + UserDefaults.standard.consecutiveNetworkFailureDays = consecutiveDays - // User messaging - appState.postNativeNotification( - id: "license_network_issue", - appName: "AirSync+", - title: "License check skipped", - body: "Network issue while validating your license. \(consecutiveDays)/3 consecutive days." - ) + appState.postNativeNotification( + id: "license_network_issue", + appName: "AirSync+", + title: "License check skipped", + body: "Network issue while validating your license. \(consecutiveDays)/3 consecutive days." + ) - if consecutiveDays >= 3 { - // Unregister on 3rd consecutive day - Gumroad().performUnregisterWithAlert(reason: "Could not validate your license for 3 consecutive days due to network issues. Please re-enter your key when you’re online.") - } else { - print("[gumroad] Network/server error during license check: \(error)") + if consecutiveDays >= 3 { + Gumroad().performUnregisterWithAlert(reason: "Could not validate your license for 3 consecutive days due to network issues. Please re-enter your key when you’re online.") + } + default: + // Non-network errors (invalid key, plan mismatch, subscription ended, etc.) + let reason = error.localizedDescription + print("[gumroad] License error during routine check: \(reason)") + appState.lastLicenseCheckFailureReason = reason + if !TrialManager.shared.isTrialActive { + appState.isPlus = false + } + Gumroad().incrementInvalidLicenseFailCount() + UserDefaults.standard.consecutiveNetworkFailureDays = 0 } } catch { - // Any other unexpected error — treat as server error category UserDefaults.standard.lastLicenseCheckDate = now let lastNetworkDay = UserDefaults.standard.object(forKey: "lastNetworkFailureDay") as? Date @@ -286,23 +361,20 @@ class Gumroad { if UserDefaults.standard.consecutiveNetworkFailureDays >= 3 { Gumroad().performUnregisterWithAlert(reason: "Could not validate your license for 3 consecutive days due to server issues. Please re-enter your key when you’re online.") } else { - print("[gumroad] Unexpected error during license check: \(error)") + print("[gumroad] Unexpected error during license check: \(error.localizedDescription)") } } } - func checkLicenseIfNeeded() async { - // If we already had a successful check today, skip to enforce "max one successful check per day" if appState.licenseDetails != nil, let lastSuccess = UserDefaults.standard.lastLicenseSuccessfulCheckDate, Calendar.current.isDateInToday(lastSuccess) { - print("[gumroad] License already successfully validated today — skipping network call.") + print("[gumroad] License already successfully validated today (\(lastSuccess.formatted())) — skipping network call.") appState.isPlus = true return } await Gumroad().checkLicense() } - } diff --git a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift index 02a4fd70..f7bc90b5 100644 --- a/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift +++ b/airsync-mac/Core/Util/MacInfo/MacInfoSyncManager.swift @@ -46,41 +46,53 @@ class MacInfoSyncManager: ObservableObject { private var cancellables = Set() init() { - // Monitor device connection status and start/stop polling accordingly - AppState.shared.$device - .sink { [weak self] device in + // Monitor device connection status and sendNowPlayingStatus toggle + Publishers.CombineLatest(AppState.shared.$device, AppState.shared.$sendNowPlayingStatus) + .sink { [weak self] device, sendNowPlayingStatus in if device != nil { - self?.startPolling() + self?.startSync(sendNowPlayingStatus: sendNowPlayingStatus) } else { - self?.stopPolling() + self?.stopSync() } } .store(in: &cancellables) } deinit { - stopPolling() + stopSync() cancellables.removeAll() } - private func startPolling() { - // Don't start if already running - guard timer == nil else { return } - + private func startSync(sendNowPlayingStatus: Bool) { print("[mac-info-sync] Starting device status monitoring - device connected") - fetch() // initial fetch - timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in - self?.fetch() + + // Reset retry count on fresh connection + NowPlayingCLI.shared.resetRetryCount() + + if timer == nil { + timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + self?.refreshBatteryStatus() + } } - } - private func stopPolling() { - guard timer != nil else { return } + if sendNowPlayingStatus { + NowPlayingCLI.shared.startStreaming { [weak self] info in + self?.handleNowPlayingUpdate(info) + } + refreshBatteryStatus() + } else { + NowPlayingCLI.shared.stopStreaming() + sendDeviceStatusWithoutMusic() + } + } + private func stopSync() { print("[mac-info-sync] Stopping media playback monitoring - device disconnected") timer?.invalidate() timer = nil + NowPlayingCLI.shared.stopStreaming() + // Reset published properties when stopping DispatchQueue.main.async { self.title = "Unknown Title" @@ -96,52 +108,46 @@ class MacInfoSyncManager: ObservableObject { } } - func fetch() { - // Only fetch if there's a connected device + func forceRefresh() { + refreshStatus() + } + + private func refreshStatus() { guard AppState.shared.device != nil else { return } + if let lastInfo = lastSentInfo, AppState.shared.sendNowPlayingStatus { + sendDeviceStatusIfNeeded(with: lastInfo) + } else { + sendDeviceStatusWithoutMusic() + } + } - // Check if now playing status is enabled - if AppState.shared.sendNowPlayingStatus { - // Fetch now playing info and send device status with music info - NowPlayingCLI.shared.fetchNowPlaying { [weak self] info in - guard let info = info else { -// print("[mac-info-sync] No now playing info") - // Still send device status without music info - self?.sendDeviceStatusWithoutMusic() - return - } + private func refreshBatteryStatus() { + refreshStatus() + } - // IMPORTANT: Filter out AirSync's own bundle ID. - // NowPlayingPublisher writes Android's media info into macOS - // MPNowPlayingInfoCenter so boringNotch can display it. - // media-control reads from the same source, so without this guard - // we'd forward AirSync's own published entry back to Android, - // creating a play/pause feedback loop. - let ownBundleId = Bundle.main.bundleIdentifier ?? "" - if let bundleId = info.bundleIdentifier, !ownBundleId.isEmpty, - bundleId == ownBundleId { - // This is our own reflection — treat as nothing playing on Mac - self?.sendDeviceStatusWithoutMusic() - return - } + private func handleNowPlayingUpdate(_ info: NowPlayingInfo?) { + guard AppState.shared.device != nil else { return } - // MUST update @Published properties on main thread - DispatchQueue.main.async { - // Set raw state first - self?.title = info.title ?? "Unknown Title" - self?.artist = info.artist ?? "Unknown Artist" - self?.album = info.album ?? "Unknown Album" - self?.elapsed = info.elapsedTime ?? 0 - self?.duration = info.duration ?? 0 - self?.isPlaying = info.isPlaying ?? false - - // Send to Android if connected and info has changed - self?.sendDeviceStatusIfNeeded(with: info) - } - } - } else { - // Now playing disabled - just send device status without music info + guard let info = info else { sendDeviceStatusWithoutMusic() + return + } + + let ownBundleId = Bundle.main.bundleIdentifier ?? "" + if let bundleId = info.bundleIdentifier, !ownBundleId.isEmpty, bundleId == ownBundleId { + sendDeviceStatusWithoutMusic() + return + } + + DispatchQueue.main.async { [weak self] in + self?.title = info.title ?? "Unknown Title" + self?.artist = info.artist ?? "Unknown Artist" + self?.album = info.album ?? "Unknown Album" + self?.elapsed = info.elapsedTime ?? 0 + self?.duration = info.duration ?? 0 + self?.isPlaying = info.isPlaying ?? false + + self?.sendDeviceStatusIfNeeded(with: info) } } diff --git a/airsync-mac/Core/Util/QRCode/QRCodeGenerator.swift b/airsync-mac/Core/Util/QRCode/QRCodeGenerator.swift index 95d9e817..1625baf6 100644 --- a/airsync-mac/Core/Util/QRCode/QRCodeGenerator.swift +++ b/airsync-mac/Core/Util/QRCode/QRCodeGenerator.swift @@ -28,9 +28,7 @@ class QRCodeGenerator { // Colors doc.design.backgroundColor(.clear) - // Accent color for eye + pupil - let accentCG = NSColor.controlAccentColor.cgColor - doc.design.style.eye = QRCode.FillStyle.Solid(accentCG) + doc.design.style.eye = QRCode.FillStyle.Solid(.white) doc.design.style.pupil = QRCode.FillStyle.Solid(.white) doc.design.style.onPixels = QRCode.FillStyle.Solid(.white) diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift index 4b663cfc..e3733e1e 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift @@ -60,7 +60,7 @@ extension WebSocketServer { let isPrimary = (sessionId == primary) if isPrimary && !isStale { - let isWeak = timeSinceLastActivity > 10 + let isWeak = timeSinceLastActivity > 12 DispatchQueue.main.async { if AppState.shared.isConnectionWeak != isWeak { AppState.shared.isConnectionWeak = isWeak diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index 6d446d2a..0a6cc823 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -174,33 +174,25 @@ class WebSocketServer: ObservableObject { guard let data = decryptedData, !data.isEmpty else { return } + self.lock.lock() + self.lastActivity[ObjectIdentifier(session)] = Date() + self.lock.unlock() + DispatchQueue.main.async { + if AppState.shared.isConnectionWeak { + AppState.shared.isConnectionWeak = false + } + } + do { let message = try self.jsonDecoder.decode(Message.self, from: data) if message.type == .status { // Pong / keepalive check if let dict = message.data.value as? [String: Any], (dict["type"] as? String) == "pong" { - self.lock.lock() - self.lastActivity[ObjectIdentifier(session)] = Date() - self.lock.unlock() - DispatchQueue.main.async { - if AppState.shared.isConnectionWeak { - AppState.shared.isConnectionWeak = false - } - } return } } - 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) } else { diff --git a/airsync-mac/Localization/en.json b/airsync-mac/Localization/en.json index b1c6aea4..d48581a9 100644 --- a/airsync-mac/Localization/en.json +++ b/airsync-mac/Localization/en.json @@ -132,6 +132,8 @@ "settings.mirroring.swapCmdCtrl": "Swap ⌘ and ⌃ (macOS familiar)", "settings.mirroring.showMirrorControls": "Show navigation buttons (⌘B)", "quickshare.copy": "Copy to clipboard", + "settings.menubar.showInMenubar": "Show in menubar", + "settings.menubar.showInMenubar.disabledInfo": "Menu bar item cannot be hidden when Dock icon is hidden to keep AirSync accessible.", "settings.menubar.showIcon": "Show menu bar icon", "settings.menubar.showText": "Show menu bar Text", "settings.menubar.enableMarquee": "Marquee text effect", diff --git a/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift b/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift index a844e305..143dd834 100644 --- a/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift +++ b/airsync-mac/Screens/ScannerView/QRScannerSidebarView.swift @@ -87,7 +87,7 @@ struct QRScannerSidebarView: View { .accessibilityLabel("QR Code") .shadow(radius: 20) .padding() - .background(.black.opacity(0.6), in: .rect(cornerRadius: 30)) + .background(Color.black, in: .rect(cornerRadius: 30)) if let key = WebSocketServer.shared.getSymmetricKeyBase64(), !key.isEmpty { VStack(spacing: 8) { diff --git a/airsync-mac/Screens/Settings/MenubarSettingsView.swift b/airsync-mac/Screens/Settings/MenubarSettingsView.swift index 6ab5b3d2..78cd7f7d 100644 --- a/airsync-mac/Screens/Settings/MenubarSettingsView.swift +++ b/airsync-mac/Screens/Settings/MenubarSettingsView.swift @@ -6,6 +6,7 @@ struct MenubarSettingsView: View { @State private var showingPlusPopover = false @State private var plusPopoverMessage = "" @State private var showMarqueeInfo = false + @State private var showShowInMenubarInfo = false @State private var isDraggingFontSize = false @State private var isDraggingTextLength = false @@ -14,6 +15,26 @@ struct MenubarSettingsView: View { VStack(alignment: .leading, spacing: 20) { SettingsHeaderView(title: L("settings.menubar"), icon: "menubar.arrow.up.rectangle") VStack(spacing: 12) { + HStack { + Label(L("settings.menubar.showInMenubar"), systemImage: "menubar.arrow.up.rectangle") + if appState.hideDockIcon { + Button(action: { showShowInMenubarInfo = true }) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .alert(L("settings.menubar.showInMenubar"), isPresented: $showShowInMenubarInfo) { + Button("OK", role: .cancel) {} + } message: { + Text(L("settings.menubar.showInMenubar.disabledInfo")) + } + } + Spacer() + Toggle("", isOn: $appState.showInMenubar) + .toggleStyle(.switch) + .disabled(appState.hideDockIcon) + } + HStack { Label(L("settings.menubar.fontSize"), systemImage: "textformat.size") Spacer() @@ -260,6 +281,7 @@ struct MenubarSettingsView: View { } } .padding() + .animation(.spring(), value: appState.showInMenubar) .animation(.spring(), value: appState.showMenubarText) .animation(.spring(), value: appState.enableMarquee) .animation(.spring(), value: appState.showMenubarIcon) diff --git a/airsync-mac/Screens/Settings/SettingsPlusView.swift b/airsync-mac/Screens/Settings/SettingsPlusView.swift index 144d388f..70495448 100644 --- a/airsync-mac/Screens/Settings/SettingsPlusView.swift +++ b/airsync-mac/Screens/Settings/SettingsPlusView.swift @@ -14,6 +14,7 @@ struct SettingsPlusView: View { @State private var licenseKey: String = "" @State private var isCheckingLicense = false @State private var licenseValid: Bool? = nil + @State private var licenseErrorMessage: String? = nil @State private var isCheckingValidity = false @State private var isExpanded: Bool = false @@ -84,17 +85,23 @@ struct SettingsPlusView: View { Task { isCheckingLicense = true licenseValid = nil + licenseErrorMessage = nil UserDefaults.standard.licensePlanType = selectedPlan - let result = try? await Gumroad().checkLicenseKeyValidity( - key: licenseKey, - save: true, - isNewRegistration: true - ) - licenseValid = result ?? false - isCheckingLicense = false - if result == true { - // Show Plus unlocked sheet - showPlusUnlockedSheet = true + do { + let result = try await Gumroad().checkLicenseKeyValidity( + key: licenseKey, + save: true, + isNewRegistration: true + ) + licenseValid = result + isCheckingLicense = false + if result { + showPlusUnlockedSheet = true + } + } catch { + licenseValid = false + isCheckingLicense = false + licenseErrorMessage = error.localizedDescription } } #endif @@ -132,6 +139,18 @@ struct SettingsPlusView: View { ) .disabled(trialManager.isPerformingRequest || !trialManager.hasSecretConfigured) } + + if let errorMsg = licenseErrorMessage, !errorMsg.isEmpty { + HStack(alignment: .top, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(errorMsg) + .font(.caption) + .foregroundColor(.red) + .multilineTextAlignment(.leading) + } + .padding(.top, 2) + } } #if !SELF_COMPILED @@ -244,8 +263,14 @@ struct SettingsPlusView: View { .transition(.opacity.combined(with: .move(edge: .top))) if details.key != "" && !appState.isPlus { - Label("License invalid, expired or network error", systemImage: "xmark.circle") - .foregroundColor(.red) + HStack(alignment: .top, spacing: 6) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.red) + Text(appState.lastLicenseCheckFailureReason ?? "License invalid, expired or network error") + .font(.caption) + .foregroundColor(.red) + .multilineTextAlignment(.leading) + } } } #endif diff --git a/airsync.rb b/airsync.rb index 42883716..aa06d308 100644 --- a/airsync.rb +++ b/airsync.rb @@ -13,18 +13,13 @@ end depends_on macos: :sonoma - depends_on cask: "android-platform-tools" - depends_on formula: [ - "media-control", - "scrcpy", - ] app "AirSync.app" zap trash: [ - "~/Library/Application Support/AirSync", - "~/Library/Caches/com.sameerasw.airsync-mac", - "~/Library/Preferences/com.sameerasw.airsync-mac.plist", - "~/Library/Saved Application State/com.sameerasw.airsync-mac.savedState", + "~/Library/Application Support/airsync-mac", + "~/Library/Caches/sameerasw.airsync-mac", + "~/Library/Preferences/sameerasw.airsync-mac.plist", + "~/Library/Saved Application State/sameerasw.airsync-mac.savedState", ] end