From 472cfb0a3d6319ab5a318739965aac9e1b9d9164 Mon Sep 17 00:00:00 2001 From: Datta Nimmaturi Date: Sat, 4 Apr 2026 12:25:27 +0530 Subject: [PATCH] Enhance network visibility --- airsync-mac/Core/AppState.swift | 79 +++++++- .../Core/Discovery/UDPDiscoveryManager.swift | 30 +-- .../QuickConnect/QuickConnectManager.swift | 31 ++- .../WebSocketServer+Networking.swift | 97 +++++++++- .../Screens/HomeScreen/AppContentView.swift | 176 ++++++++++-------- .../PhoneView/ConnectionStatusPill.swift | 43 ++++- .../Screens/ScannerView/DeviceCard.swift | 7 +- .../Screens/ScannerView/ScannerView.swift | 15 +- build-enhanced.sh | 70 +++++++ 9 files changed, 413 insertions(+), 135 deletions(-) create mode 100755 build-enhanced.sh diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 73411148..7d97edba 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -80,7 +80,7 @@ class AppState: ObservableObject { let validatedAdapter = AppState.validateAndGetNetworkAdapter(savedName: savedAdapterName) self.selectedNetworkAdapterName = validatedAdapter - let adapterIP = WebSocketServer.shared.getLocalIPAddress(adapterName: validatedAdapter) ?? "N/A" + let adapterIP = WebSocketServer.shared.getPrimaryConnectionIP(adapterName: validatedAdapter) ?? "N/A" let deviceName = UserDefaults.standard.string(forKey: "deviceName") ?? (Host.current().localizedName ?? "My Mac") let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "2.0.0" let portNumStr = UserDefaults.standard.string(forKey: "devicePort") ?? String(Defaults.serverPort) @@ -185,14 +185,85 @@ class AppState: ObservableObject { @Published var recentApps: [AndroidApp] = [] var isConnectedOverLocalNetwork: Bool { - guard let ip = device?.ipAddress else { return true } - // Tailscale IPs usually start with 100. - return !ip.hasPrefix("100.") + transportConnectionType == .local + } + + var connectionTransportLabel: String { + switch transportConnectionType { + case .local: + return "LAN" + case .extended: + return "Internet" + case .unknown: + return "Unknown" + } + } + + var connectionTransportHelp: String { + switch transportConnectionType { + case .local: + return "Connected directly over the local network" + case .extended: + return "Connected over an extended path such as Tailscale or another routed network" + case .unknown: + return "Connection transport is not available" + } + } + + var connectionTransportIcon: String { + switch transportConnectionType { + case .local: + return "wifi" + case .extended: + return "globe" + case .unknown: + return "questionmark.circle" + } } // Audio player for ringtone private var ringtonePlayer: AVAudioPlayer? + private enum ConnectionTransportType { + case local + case extended + case unknown + } + + private var transportConnectionType: ConnectionTransportType { + guard let ip = connectionTransportIP else { return .unknown } + return AppState.classifyConnectionIP(ip) + } + + private var connectionTransportIP: String? { + if let active = activeMacIp?.trimmingCharacters(in: .whitespaces), !active.isEmpty { + return active + } + if let deviceIP = device?.ipAddress.trimmingCharacters(in: .whitespaces), !deviceIP.isEmpty { + return deviceIP + } + return nil + } + + private static func classifyConnectionIP(_ ip: String) -> ConnectionTransportType { + if ip.hasPrefix("192.168.") || ip.hasPrefix("10.") || ip.hasPrefix("127.") || ip.hasPrefix("169.254.") { + return .local + } + + if ip.hasPrefix("172.") { + let parts = ip.split(separator: ".") + if parts.count > 1, let secondOctet = Int(parts[1]), (16...31).contains(secondOctet) { + return .local + } + } + + if ip.hasPrefix("100.") { + return .extended + } + + return .extended + } + @Published var selectedNetworkAdapterName: String? { // e.g., "en0" didSet { UserDefaults.standard.set(selectedNetworkAdapterName, forKey: "selectedNetworkAdapterName") diff --git a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift index 431f3a45..2115fe05 100644 --- a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift +++ b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift @@ -115,13 +115,17 @@ class UDPDiscoveryManager: ObservableObject { } func broadcastPresence() { - let adapters = WebSocketServer.shared.getAvailableNetworkAdapters() - guard !adapters.isEmpty else { return } + let candidateIPs = WebSocketServer.shared.getConnectionCandidateIPs( + adapterName: AppState.shared.selectedNetworkAdapterName + ) + guard !candidateIPs.isEmpty else { return } // knownPeerIPs: List of IPs we have connected to before (e.g. Android on Tailscale) - let knownPeerIPs = Set(QuickConnectManager.shared.lastConnectedDevices.values.map { $0.ipAddress }) - - let allIPs = adapters.map { $0.address } + let knownPeerIPs = Set( + QuickConnectManager.shared.lastConnectedDevices.values + .map { $0.ipAddress } + .filter { !$0.hasPrefix("100.") } + ) let info = AppState.shared.myDevice let port = info?.port ?? Int(Defaults.serverPort) @@ -134,14 +138,14 @@ class UDPDiscoveryManager: ObservableObject { "deviceType": "mac", "id": uuid, "name": name, - "ips": allIPs, // Send ALL IPs + "ips": candidateIPs, "port": port ] if let data = try? JSONSerialization.data(withJSONObject: payload), let jsonString = String(data: data, encoding: .utf8) { - for adapter in adapters { - sendBroadcast(message: jsonString, sourceIP: adapter.address) + for sourceIP in candidateIPs { + sendBroadcast(message: jsonString, sourceIP: sourceIP) } } @@ -149,7 +153,7 @@ class UDPDiscoveryManager: ObservableObject { let jsonString = String(data: data, encoding: .utf8) { for peerIP in knownPeerIPs { - if allIPs.contains(peerIP) { continue } + if candidateIPs.contains(peerIP) { continue } sendUnicast(message: jsonString, targetIP: peerIP) } } @@ -336,8 +340,12 @@ class UDPDiscoveryManager: ObservableObject { /// IP validation private func isValidCandidateIP(_ ip: String) -> Bool { - // 1. Allow Tailscale (100.x.x.x) - if ip.hasPrefix("100.") { return true } + let localCandidates = WebSocketServer.shared.getConnectionCandidateIPs( + adapterName: AppState.shared.selectedNetworkAdapterName + ) + let hasLocalLAN = localCandidates.contains { !$0.hasPrefix("100.") } + + if ip.hasPrefix("100.") { return !hasLocalLAN } // 2. Allow Standard Local LAN (192.168.x.x) if ip.hasPrefix("192.168.") { return true } diff --git a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift index 9a6c842c..533251a0 100644 --- a/airsync-mac/Core/QuickConnect/QuickConnectManager.swift +++ b/airsync-mac/Core/QuickConnect/QuickConnectManager.swift @@ -61,8 +61,12 @@ class QuickConnectManager: ObservableObject { /// Attempts to wake up and reconnect to a specific discovered device func connect(to discoveredDevice: DiscoveredDevice) { - // Pick best IP: prefer local (non-100.x) over VPN - let bestIP = discoveredDevice.ips.first(where: { !$0.hasPrefix("100.") }) ?? discoveredDevice.ips.first ?? "" + let bestIP = discoveredDevice.ips.sorted { + let lhsIsTailscale = $0.hasPrefix("100.") + let rhsIsTailscale = $1.hasPrefix("100.") + if lhsIsTailscale != rhsIsTailscale { return !lhsIsTailscale } + return $0 < $1 + }.first ?? "" // Convert DiscoveredDevice to Device model let device = Device( @@ -105,7 +109,7 @@ class QuickConnectManager: ObservableObject { // MARK: - Private Implementation private func getCurrentMacIP() -> String? { - return WebSocketServer.shared.getLocalIPAddress( + return WebSocketServer.shared.getPrimaryConnectionIP( adapterName: AppState.shared.selectedNetworkAdapterName ) } @@ -162,29 +166,22 @@ class QuickConnectManager: ObservableObject { /// Selects the best local IP to present to the target device /// Prioritizes IPs that match the target's subnet/prefix (e.g. Tailscale 100.x) private func getBestLocalIP(for targetIP: String) -> String? { - let adapters = WebSocketServer.shared.getAvailableNetworkAdapters() - let allIPs = adapters.map { $0.address } + let allIPs = WebSocketServer.shared.getConnectionCandidateIPs( + adapterName: AppState.shared.selectedNetworkAdapterName, + includeExpandedNetworking: false + ) // 1. If user manually selected an adapter, MUST use that if let selected = AppState.shared.selectedNetworkAdapterName { - if let match = adapters.first(where: { $0.name == selected }) { + if let match = WebSocketServer.shared.getAvailableNetworkAdapters().first(where: { $0.name == selected }) { return match.address } } - // 2. If valid target IP, try to match prefix if !targetIP.isEmpty { - // Check for Tailscale (100.x) - if targetIP.hasPrefix("100.") { - if let tailscaleIP = allIPs.first(where: { $0.hasPrefix("100.") }) { - return tailscaleIP - } - } - - // Check for other common prefixes (subnet match) let parts = targetIP.split(separator: ".") - if let firstOctet = parts.first { - let prefix = "\(firstOctet)." + if parts.count >= 3 { + let prefix = "\(parts[0]).\(parts[1]).\(parts[2])." if let match = allIPs.first(where: { $0.hasPrefix(prefix) }) { return match } diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift index a3281768..90f097f2 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Networking.swift @@ -13,30 +13,99 @@ extension WebSocketServer { // MARK: - Local IP handling + private func isTailscaleIP(_ ip: String) -> Bool { + ip.hasPrefix("100.") + } + + private func isPrivateLANIP(_ ip: String) -> Bool { + if ip.hasPrefix("192.168.") || ip.hasPrefix("10.") { return true } + if ip.hasPrefix("172.") { + let parts = ip.split(separator: ".") + if parts.count > 1, let second = Int(parts[1]), (16...31).contains(second) { + return true + } + } + return false + } + + private func rankConnectionIPs(_ ips: [String], includeExpandedNetworking: Bool) -> [String] { + let sanitized = ips + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let filtered = sanitized.filter { includeExpandedNetworking || !isTailscaleIP($0) } + let base = filtered.isEmpty ? sanitized : filtered + + let ranked = uniqueStrings(base) + return ranked.sorted { + let lhsRank = connectionPriority(for: $0, includeExpandedNetworking: includeExpandedNetworking) + let rhsRank = connectionPriority(for: $1, includeExpandedNetworking: includeExpandedNetworking) + if lhsRank != rhsRank { return lhsRank < rhsRank } + return $0 < $1 + } + } + + private func connectionPriority(for ip: String, includeExpandedNetworking: Bool) -> Int { + if isPrivateLANIP(ip) && !isTailscaleIP(ip) { return 0 } + if includeExpandedNetworking && isTailscaleIP(ip) { return 1 } + if isTailscaleIP(ip) { return 2 } + return 3 + } + + func getConnectionCandidateIPs( + adapterName: String?, + includeExpandedNetworking: Bool = false + ) -> [String] { + let adapters = getAvailableNetworkAdapters() + let rankedAll = rankConnectionIPs(adapters.map { $0.address }, includeExpandedNetworking: includeExpandedNetworking) + + guard let adapterName else { + return rankedAll + } + + guard let exact = adapters.first(where: { $0.name == adapterName }) else { + return rankedAll + } + + let manualFirst = [exact.address] + rankedAll.filter { $0 != exact.address } + return rankConnectionIPs( + manualFirst, + includeExpandedNetworking: includeExpandedNetworking || isTailscaleIP(exact.address) + ) + } + + func getPrimaryConnectionIP( + adapterName: String?, + includeExpandedNetworking: Bool = false + ) -> String? { + getConnectionCandidateIPs( + adapterName: adapterName, + includeExpandedNetworking: includeExpandedNetworking + ).first + } + /// Retrieves the local IP address based on configuration. /// Supports binding to a specific adapter or auto-discovery of all available non-loopback interfaces. func getLocalIPAddress(adapterName: String?) -> String? { - let adapters = getAvailableNetworkAdapters() - if let adapterName = adapterName { - if let exact = adapters.first(where: { $0.name == adapterName }) { + let candidates = getConnectionCandidateIPs(adapterName: adapterName) + if let exact = candidates.first { self.lock.lock() let lastLogged = lastLoggedSelectedAdapter self.lock.unlock() - if lastLogged?.name != exact.name || lastLogged?.address != exact.address { - print("[websocket] Selected adapter match: \(exact.name) -> \(exact.address)") + if lastLogged?.name != adapterName || lastLogged?.address != exact { + print("[websocket] Selected adapter match: \(adapterName) -> \(exact)") self.lock.lock() - lastLoggedSelectedAdapter = (exact.name, exact.address) + lastLoggedSelectedAdapter = (adapterName, exact) self.lock.unlock() } - return exact.address + return exact } } // Auto mode if adapterName == nil { - let allAddresses = adapters.map { $0.address } + let allAddresses = getConnectionCandidateIPs(adapterName: nil) if !allAddresses.isEmpty { let joined = allAddresses.joined(separator: ",") @@ -87,7 +156,7 @@ extension WebSocketServer { } freeifaddrs(ifaddr) } - return adapters + return uniqueAdapters(adapters) } func ipIsPrivatePreferred(_ ip: String) -> Bool { @@ -102,6 +171,16 @@ extension WebSocketServer { return false } + private func uniqueStrings(_ values: [String]) -> [String] { + var seen = Set() + return values.filter { seen.insert($0).inserted } + } + + private func uniqueAdapters(_ adapters: [(name: String, address: String)]) -> [(name: String, address: String)] { + var seen = Set() + return adapters.filter { seen.insert("\($0.name)|\($0.address)").inserted } + } + // MARK: - Network Monitoring func startNetworkMonitoring() { diff --git a/airsync-mac/Screens/HomeScreen/AppContentView.swift b/airsync-mac/Screens/HomeScreen/AppContentView.swift index 90dbd6f2..a28c0818 100644 --- a/airsync-mac/Screens/HomeScreen/AppContentView.swift +++ b/airsync-mac/Screens/HomeScreen/AppContentView.swift @@ -15,108 +15,120 @@ struct AppContentView: View { @State private var showDisconnectAlert = false var body: some View { - TabView(selection: $appState.selectedTab) { - // QR Scanner Tab (only when device is NOT connected) - if appState.device == nil { - ScannerView() - .tabItem { - Image(systemName: "qrcode") - // Label("Scan", systemImage: "qrcode") - } - .tag(TabIdentifier.qr) - .toolbar { - ToolbarItemGroup { - Button("Help", systemImage: "questionmark.circle") { - showHelpSheet = true - } - .help("Feedback and How to?") + VStack(spacing: 0) { + if appState.device != nil { + HStack { + Spacer() + ConnectionStatusPill() + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 6) + } + + TabView(selection: $appState.selectedTab) { + // QR Scanner Tab (only when device is NOT connected) + if appState.device == nil { + ScannerView() + .tabItem { + Image(systemName: "qrcode") + // Label("Scan", systemImage: "qrcode") + } + .tag(TabIdentifier.qr) + .toolbar { + ToolbarItemGroup { + Button("Help", systemImage: "questionmark.circle") { + showHelpSheet = true + } + .help("Feedback and How to?") - Button("Refresh", systemImage: "repeat") { - WebSocketServer.shared.stop() - WebSocketServer.shared.start() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - appState.shouldRefreshQR = true + Button("Refresh", systemImage: "repeat") { + WebSocketServer.shared.stop() + WebSocketServer.shared.start() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + appState.shouldRefreshQR = true + } } + .help("Refresh server") } - .help("Refresh server") } - } - } + } - // Notifications Tab (only when device connected) - if appState.device != nil { - NotificationView() - .tabItem { - Image(systemName: "bell.badge") - // Label("Notifications", systemImage: "bell.badge") - } - .tag(TabIdentifier.notifications) - .toolbar { - if appState.notifications.count > 0 || appState.callEvents.count > 0 { - ToolbarItem(placement: .primaryAction) { - Button { - notificationStacks.toggle() - } label: { - Label("Toggle Notification Stacks", systemImage: notificationStacks ? "mail" : "mail.stack") + // Notifications Tab (only when device connected) + if appState.device != nil { + NotificationView() + .tabItem { + Image(systemName: "bell.badge") + // Label("Notifications", systemImage: "bell.badge") + } + .tag(TabIdentifier.notifications) + .toolbar { + if appState.notifications.count > 0 || appState.callEvents.count > 0 { + ToolbarItem(placement: .primaryAction) { + Button { + notificationStacks.toggle() + } label: { + Label("Toggle Notification Stacks", systemImage: notificationStacks ? "mail" : "mail.stack") + } + .help(notificationStacks ? "Switch to stacked view" : "Switch to expanded view") } - .help(notificationStacks ? "Switch to stacked view" : "Switch to expanded view") - } - ToolbarItem(placement: .primaryAction) { - Button { - appState.clearNotifications() - } label: { - Label("Clear", systemImage: "wind") + ToolbarItem(placement: .primaryAction) { + Button { + appState.clearNotifications() + } label: { + Label("Clear", systemImage: "wind") + } + .help("Clear all notifications") + .keyboardShortcut(.delete, modifiers: .command) + .badge(appState.notifications.count + appState.callEvents.count) } - .help("Clear all notifications") - .keyboardShortcut(.delete, modifiers: .command) - .badge(appState.notifications.count + appState.callEvents.count) } } - } - // Apps Tab - AppsView() - .tabItem { - Image(systemName: "app") - // Label("Apps", systemImage: "app") - } - .tag(TabIdentifier.apps) - - } + // Apps Tab + AppsView() + .tabItem { + Image(systemName: "app") + // Label("Apps", systemImage: "app") + } + .tag(TabIdentifier.apps) - // Settings Tab - SettingsView() - .tabItem { - // Label("Settings", systemImage: "gear") - Image(systemName: "gear") } - .tag(TabIdentifier.settings) - .toolbar { - ToolbarItemGroup { - Button("Help", systemImage: "questionmark.circle") { - showHelpSheet = true - } - .help("Feedback and How to?") - Button { - showAboutSheet = true - } label: { - Label("About", systemImage: "info") - } - .help("View app information and version details") + // Settings Tab + SettingsView() + .tabItem { + // Label("Settings", systemImage: "gear") + Image(systemName: "gear") } - - if appState.device != nil { + .tag(TabIdentifier.settings) + .toolbar { ToolbarItemGroup { + Button("Help", systemImage: "questionmark.circle") { + showHelpSheet = true + } + .help("Feedback and How to?") + Button { - showDisconnectAlert = true + showAboutSheet = true } label: { - Label("Disconnect", systemImage: "iphone.slash") + Label("About", systemImage: "info") + } + .help("View app information and version details") + } + + if appState.device != nil { + ToolbarItemGroup { + Button { + showDisconnectAlert = true + } label: { + Label("Disconnect", systemImage: "iphone.slash") + } + .help("Disconnect Device") } - .help("Disconnect Device") } } - } + } } .tabViewStyle(.automatic) .frame(minWidth: 550) diff --git a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift index 32bc5357..335731bd 100644 --- a/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift +++ b/airsync-mac/Screens/HomeScreen/PhoneView/ConnectionStatusPill.swift @@ -17,10 +17,28 @@ struct ConnectionStatusPill: View { showingPopover.toggle() }) { HStack(spacing: 8) { - // Network Connection Icon - Image(systemName: appState.isConnectedOverLocalNetwork ? "wifi" : "globe") - .contentTransition(.symbolEffect(.replace)) - .help(appState.isConnectedOverLocalNetwork ? "Local WiFi" : "Extended Connection (Tailscale)") + if appState.device != nil { + HStack(spacing: 6) { + Image(systemName: appState.connectionTransportIcon) + .contentTransition(.symbolEffect(.replace)) + + Text(appState.connectionTransportLabel) + .font(.caption.weight(.semibold)) + } + .foregroundStyle(transportTint) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(transportTint.opacity(0.14), in: Capsule()) + .overlay( + Capsule() + .stroke(transportTint.opacity(0.22), lineWidth: 1) + ) + .help(appState.connectionTransportHelp) + } else { + Image(systemName: appState.connectionTransportIcon) + .contentTransition(.symbolEffect(.replace)) + .help(appState.connectionTransportHelp) + } if appState.isPlus { if appState.adbConnecting { @@ -78,6 +96,17 @@ struct ConnectionStatusPill: View { ConnectionPillPopover() } } + + private var transportTint: Color { + switch appState.connectionTransportLabel { + case "LAN": + return .green + case "Internet": + return .orange + default: + return .secondary + } + } private var adbModeIcon: String { switch appState.adbConnectionMode { @@ -122,6 +151,12 @@ struct ConnectionPillPopover: View { text: currentIPAddress, activeIp: appState.activeMacIp ) + + ConnectionInfoText( + label: "Transport", + icon: appState.connectionTransportIcon, + text: appState.connectionTransportLabel + ) if appState.isPlus && appState.adbConnected { ConnectionInfoText( diff --git a/airsync-mac/Screens/ScannerView/DeviceCard.swift b/airsync-mac/Screens/ScannerView/DeviceCard.swift index f5bf6457..223757b3 100644 --- a/airsync-mac/Screens/ScannerView/DeviceCard.swift +++ b/airsync-mac/Screens/ScannerView/DeviceCard.swift @@ -97,7 +97,12 @@ struct DeviceCard: View { // Show primary IP - let displayIP = device.ips.first(where: { !$0.hasPrefix("100.") }) ?? device.ips.first ?? "" + let displayIP = device.ips.sorted { + let lhsIsTailscale = $0.hasPrefix("100.") + let rhsIsTailscale = $1.hasPrefix("100.") + if lhsIsTailscale != rhsIsTailscale { return !lhsIsTailscale } + return $0 < $1 + }.first ?? "" Text(displayIP) .font(.caption) .foregroundColor(.secondary) diff --git a/airsync-mac/Screens/ScannerView/ScannerView.swift b/airsync-mac/Screens/ScannerView/ScannerView.swift index 84f2e7e0..3b780e12 100644 --- a/airsync-mac/Screens/ScannerView/ScannerView.swift +++ b/airsync-mac/Screens/ScannerView/ScannerView.swift @@ -248,13 +248,13 @@ struct ScannerView: View { } func generateQRAsync() { - let ip = WebSocketServer.shared - .getLocalIPAddress( + let candidateIPs = WebSocketServer.shared + .getConnectionCandidateIPs( adapterName: appState.selectedNetworkAdapterName ) // Check if we have a valid IP address - guard let validIP = ip else { + guard !candidateIPs.isEmpty else { DispatchQueue.main.async { self.hasValidIP = false self.qrImage = nil @@ -269,7 +269,7 @@ struct ScannerView: View { } let text = generateQRText( - ip: validIP, + ips: candidateIPs, port: UInt16(appState.myDevice?.port ?? Int(Defaults.serverPort)), name: appState.myDevice?.name, key: WebSocketServer.shared.getSymmetricKeyBase64() ?? "" @@ -301,13 +301,14 @@ struct ScannerView: View { } } -func generateQRText(ip: String?, port: UInt16?, name: String?, key: String) -> String? { - guard let ip = ip, let port = port else { +func generateQRText(ips: [String], port: UInt16?, name: String?, key: String) -> String? { + guard !ips.isEmpty, let port = port else { return nil } let encodedName = name?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "My Mac" - return "airsync://\(ip):\(port)?name=\(encodedName)?plus=\(AppState.shared.isPlus)?key=\(key)" + let authority = ips.joined(separator: ",") + return "airsync://\(authority):\(port)?name=\(encodedName)?plus=\(AppState.shared.isPlus)?key=\(key)" } #Preview { diff --git a/build-enhanced.sh b/build-enhanced.sh new file mode 100755 index 00000000..20e19bed --- /dev/null +++ b/build-enhanced.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +set -euo pipefail + +SCHEME="AirSync Self Compiled" +CONFIGURATION="${CONFIGURATION:-Debug}" +DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-./build}" +RELEASE_DIR="${RELEASE_DIR:-./release}" +INSTALL_DIR="${INSTALL_DIR:-$HOME/Applications}" + +RUN_APP=false +INSTALL_APP=false + +for arg in "$@"; do + case "$arg" in + --run) + RUN_APP=true + ;; + --install) + INSTALL_APP=true + ;; + esac +done + +echo "Building AirSync macOS (${CONFIGURATION})..." + +if [ ! -d "AirSync.xcodeproj" ]; then + echo "AirSync.xcodeproj not found" + exit 1 +fi + +mkdir -p "${RELEASE_DIR}" + +xcodebuild \ + -project AirSync.xcodeproj \ + -scheme "${SCHEME}" \ + -configuration "${CONFIGURATION}" \ + -sdk macosx \ + -derivedDataPath "${DERIVED_DATA_PATH}" \ + CODE_SIGNING_ALLOWED=NO \ + build + +APP_PATH="$(find "${DERIVED_DATA_PATH}/Build/Products" -path "*/AirSync.app" -type d | head -1)" +if [ -z "${APP_PATH}" ]; then + echo "Built app not found" + exit 1 +fi + +rm -rf "${RELEASE_DIR}/AirSync.app" +cp -R "${APP_PATH}" "${RELEASE_DIR}/AirSync.app" + +echo "Packaged app: ${RELEASE_DIR}/AirSync.app" + +if [ "${INSTALL_APP}" = true ]; then + mkdir -p "${INSTALL_DIR}" + rm -rf "${INSTALL_DIR}/AirSync.app" + cp -R "${RELEASE_DIR}/AirSync.app" "${INSTALL_DIR}/AirSync.app" + echo "Installed app: ${INSTALL_DIR}/AirSync.app" +fi + +if [ "${RUN_APP}" = true ]; then + pkill -x AirSync >/dev/null 2>&1 || true + if [ "${INSTALL_APP}" = true ]; then + open "${INSTALL_DIR}/AirSync.app" + echo "Launched ${INSTALL_DIR}/AirSync.app" + else + open "${RELEASE_DIR}/AirSync.app" + echo "Launched ${RELEASE_DIR}/AirSync.app" + fi +fi