From 72f47af4d761c83c152d9b286011969ee2b67003 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 21 May 2026 14:37:10 +0530 Subject: [PATCH 1/3] feat: WebDAV for remote file browsing --- airsync-mac/Core/AppState.swift | 9 ++ airsync-mac/Core/Storage/WebDAVManager.swift | 96 +++++++++++++++++++ airsync-mac/Localization/en.json | 1 + .../Screens/MenubarView/MenubarSegments.swift | 10 ++ 4 files changed, 116 insertions(+) create mode 100644 airsync-mac/Core/Storage/WebDAVManager.swift diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 2fbb5b75..46a7496c 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -134,6 +134,9 @@ class AppState: ObservableObject { // Reset mirroring state on launch to prevent auto-opening if it was open during last session self.isNativeMirroring = false + + // Cleanup stale WebDAV mounts from previous sessions + WebDAVManager.shared.unmount() } @Published var minAndroidVersion = Bundle.main.infoDictionary?["AndroidVersion"] as? String ?? "2.0.0" @@ -146,8 +149,14 @@ class AppState: ObservableObject { // Validate pinned apps when connecting to a device validatePinnedApps() loadRecentApps() + + // Mount WebDAV volume + if newDevice.ipAddress != "BLE" { + WebDAVManager.shared.mount(ipAddress: newDevice.ipAddress, port: 9081, volumeName: newDevice.name) + } } else { recentApps = [] + WebDAVManager.shared.unmount() } // Automatically switch to the appropriate tab when device connection state changes diff --git a/airsync-mac/Core/Storage/WebDAVManager.swift b/airsync-mac/Core/Storage/WebDAVManager.swift new file mode 100644 index 00000000..f29d7735 --- /dev/null +++ b/airsync-mac/Core/Storage/WebDAVManager.swift @@ -0,0 +1,96 @@ +// +// WebDAVManager.swift +// airsync-mac +// +// Created by Sameera Sandakelum on 2026-05-21. +// + +import Foundation +import Cocoa + +class WebDAVManager { + static let shared = WebDAVManager() + + private let mountPoint = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Caches/com.airsync.mac/AndroidVolume") + + private var isMounted = false + + private init() {} + + func mount(ipAddress: String, port: Int = 9081, volumeName: String = "Android") { + guard !isMounted else { return } + + let urlString = "http://\(ipAddress):\(port)/" + + DispatchQueue.global(qos: .userInitiated).async { + // 1. Force unmount and clean directory + self.unmountSilently() + + do { + if FileManager.default.fileExists(atPath: self.mountPoint.path) { + try? FileManager.default.removeItem(at: self.mountPoint) + } + try FileManager.default.createDirectory(at: self.mountPoint, withIntermediateDirectories: true) + + // 2. Mount the WebDAV server + let process = Process() + process.executableURL = URL(fileURLWithPath: "/sbin/mount_webdav") + + // WebDAV URLs should have a trailing slash for the root + let finalUrl = urlString.hasSuffix("/") ? urlString : "\(urlString)/" + + print("[webdav] Attempting to mount \(finalUrl) to \(self.mountPoint.path)") + + // Revert to simple command that works + process.arguments = [finalUrl, self.mountPoint.path] + + try process.run() + process.waitUntilExit() + + if process.terminationStatus == 0 { + self.isMounted = true + print("[webdav] Successfully mounted Android volume") + + // Automatically open the folder in Finder for the user + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.openInFinder() + } + } else { + print("[webdav] Failed to mount WebDAV volume. Status: \(process.terminationStatus)") + } + } catch { + print("[webdav] Error in mount process: \(error)") + } + } + } + + func unmount() { + DispatchQueue.global(qos: .userInitiated).async { + self.unmountSilently() + self.isMounted = false + } + } + + private func unmountSilently() { + // Try diskutil first + let diskutil = Process() + diskutil.executableURL = URL(fileURLWithPath: "/usr/sbin/diskutil") + diskutil.arguments = ["unmount", "force", self.mountPoint.path] + try? diskutil.run() + diskutil.waitUntilExit() + + // Fallback to umount if directory still exists or diskutil failed + let umount = Process() + umount.executableURL = URL(fileURLWithPath: "/sbin/umount") + umount.arguments = ["-f", self.mountPoint.path] + try? umount.run() + umount.waitUntilExit() + } + + func openInFinder() { + if FileManager.default.fileExists(atPath: mountPoint.path) { + NSWorkspace.shared.open(mountPoint) + } + } +} diff --git a/airsync-mac/Localization/en.json b/airsync-mac/Localization/en.json index 9204b1ef..bcd8698e 100644 --- a/airsync-mac/Localization/en.json +++ b/airsync-mac/Localization/en.json @@ -1,6 +1,7 @@ { "app.name": "AirSync", "menu.mirror": "Mirror", + "menu.browseFiles": "Browse Files", "menu.about": "About AirSync", "menu.checkUpdates": "Check for Updates…", "menu.quit": "Quit", diff --git a/airsync-mac/Screens/MenubarView/MenubarSegments.swift b/airsync-mac/Screens/MenubarView/MenubarSegments.swift index eedb09ab..1ab61f24 100644 --- a/airsync-mac/Screens/MenubarView/MenubarSegments.swift +++ b/airsync-mac/Screens/MenubarView/MenubarSegments.swift @@ -73,6 +73,16 @@ struct TopSegmentView: View { openQuickShare() } ) + + GlassButtonView( + label: L("menu.browseFiles"), + systemImage: "folder", + iconOnly: true, + circleSize: toolButtonSize, + action: { + WebDAVManager.shared.openInFinder() + } + ) if appState.adbConnected { GlassButtonView( From 6540fd8f07afbe057d832e3e125da1baafdea370 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 21 May 2026 14:42:35 +0530 Subject: [PATCH 2/3] refactor: remove automatic Finder window opening after WebDAV volume mount --- airsync-mac/Core/Storage/WebDAVManager.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/airsync-mac/Core/Storage/WebDAVManager.swift b/airsync-mac/Core/Storage/WebDAVManager.swift index f29d7735..719b9263 100644 --- a/airsync-mac/Core/Storage/WebDAVManager.swift +++ b/airsync-mac/Core/Storage/WebDAVManager.swift @@ -51,11 +51,6 @@ class WebDAVManager { if process.terminationStatus == 0 { self.isMounted = true print("[webdav] Successfully mounted Android volume") - - // Automatically open the folder in Finder for the user - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.openInFinder() - } } else { print("[webdav] Failed to mount WebDAV volume. Status: \(process.terminationStatus)") } From 99191cadf74ddea3515feddf5e0b2b795b8ea01c Mon Sep 17 00:00:00 2001 From: sameerasw Date: Thu, 21 May 2026 17:57:38 +0530 Subject: [PATCH 3/3] feat: WebDAV toggle and plus license handling --- airsync-mac/Core/AppState.swift | 29 ++++++++++++- airsync-mac/Localization/en.json | 5 ++- .../Screens/Settings/SettingsView.swift | 42 +++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/airsync-mac/Core/AppState.swift b/airsync-mac/Core/AppState.swift index 46a7496c..1f9076fe 100644 --- a/airsync-mac/Core/AppState.swift +++ b/airsync-mac/Core/AppState.swift @@ -57,6 +57,7 @@ class AppState: ObservableObject { self.autoAcceptQuickShare = UserDefaults.standard.bool(forKey: "autoAcceptQuickShare") self.quickShareEnabled = UserDefaults.standard.object(forKey: "quickShareEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "quickShareEnabled") + self.isFileAccessEnabled = UserDefaults.standard.object(forKey: "isFileAccessEnabled") == nil ? true : UserDefaults.standard.bool(forKey: "isFileAccessEnabled") let savedNotificationMode = UserDefaults.standard.string(forKey: "callNotificationMode") ?? CallNotificationMode.popup.rawValue self.callNotificationMode = CallNotificationMode(rawValue: savedNotificationMode) ?? .popup @@ -151,7 +152,7 @@ class AppState: ObservableObject { loadRecentApps() // Mount WebDAV volume - if newDevice.ipAddress != "BLE" { + if newDevice.ipAddress != "BLE" && isPlus && isFileAccessEnabled { WebDAVManager.shared.mount(ipAddress: newDevice.ipAddress, port: 9081, volumeName: newDevice.name) } } else { @@ -438,6 +439,27 @@ class AppState: ObservableObject { } } + @Published var isFileAccessEnabled: Bool { + didSet { + if !isPlus && licenseCheck { + if isFileAccessEnabled { + isFileAccessEnabled = false + } + UserDefaults.standard.set(false, forKey: "isFileAccessEnabled") + WebDAVManager.shared.unmount() + } else { + UserDefaults.standard.set(isFileAccessEnabled, forKey: "isFileAccessEnabled") + if isFileAccessEnabled { + if let newDevice = device, newDevice.ipAddress != "BLE" { + WebDAVManager.shared.mount(ipAddress: newDevice.ipAddress, port: 9081, volumeName: newDevice.name) + } + } else { + WebDAVManager.shared.unmount() + } + } + } + } + @Published var isCrashReportingEnabled: Bool { didSet { UserDefaults.standard.set(isCrashReportingEnabled, forKey: "isCrashReportingEnabled") @@ -482,6 +504,11 @@ class AppState: ObservableObject { if !shouldSkipSave { UserDefaults.standard.set(isPlus, forKey: "isPlus") } + if !isPlus && licenseCheck { + if isFileAccessEnabled { + isFileAccessEnabled = false + } + } // Notify about license status change for icon revert logic NotificationCenter.default.post(name: NSNotification.Name("LicenseStatusChanged"), object: nil) } diff --git a/airsync-mac/Localization/en.json b/airsync-mac/Localization/en.json index bcd8698e..30cd2611 100644 --- a/airsync-mac/Localization/en.json +++ b/airsync-mac/Localization/en.json @@ -62,5 +62,8 @@ "quickshare.n_files": "%d files", "quickshare.drop.send_to": "Send to %@", "quickshare.drop.pick_device": "Pick a device to send", - "settings.quickshare": "Quick Share" + "settings.quickshare": "Quick Share and Files", + "settings.fileAccess.title": "File Access", + "settings.fileAccess.enabled": "Enable File Access", + "settings.fileAccess.description": "Mount your Android device storage as a local drive in macOS Finder automatically." } \ No newline at end of file diff --git a/airsync-mac/Screens/Settings/SettingsView.swift b/airsync-mac/Screens/Settings/SettingsView.swift index e322a13c..106f10da 100644 --- a/airsync-mac/Screens/Settings/SettingsView.swift +++ b/airsync-mac/Screens/Settings/SettingsView.swift @@ -10,6 +10,7 @@ struct SettingsView: View { @State private var availableAdapters: [(name: String, address: String)] = [] @State private var currentIPAddress: String = "N/A" @State private var showRemoteSheet = false + @State private var showingPlusPopover = false var body: some View { Group { @@ -164,6 +165,47 @@ struct SettingsView: View { } .padding() .glassBoxIfAvailable(radius: 18) + + headerSection(title: Localizer.shared.text("settings.fileAccess.title"), icon: "folder.badge.gearshape") + VStack(alignment: .leading, spacing: 8) { + HStack { + ZStack { + HStack { + Label(Localizer.shared.text("settings.fileAccess.enabled"), systemImage: "externaldrive") + Spacer() + Toggle("", isOn: $appState.isFileAccessEnabled) + .toggleStyle(.switch) + .disabled(!AppState.shared.isPlus && AppState.shared.licenseCheck) + } + + if !AppState.shared.isPlus && AppState.shared.licenseCheck { + HStack { + Spacer() + Rectangle() + .fill(Color.clear) + .contentShape(Rectangle()) + .onTapGesture { + showingPlusPopover = true + } + .frame(width: 500) + } + } + } + } + .popover(isPresented: $showingPlusPopover, arrowEdge: .bottom) { + PlusFeaturePopover(message: "File Access feature is available in AirSync+") + .onTapGesture { + showingPlusPopover = false + } + } + + Text(Localizer.shared.text("settings.fileAccess.description")) + .font(.caption) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding() + .glassBoxIfAvailable(radius: 18) } .padding() }