Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions airsync-mac/Core/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -134,6 +135,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"
Expand All @@ -146,8 +150,14 @@ class AppState: ObservableObject {
// Validate pinned apps when connecting to a device
validatePinnedApps()
loadRecentApps()

// Mount WebDAV volume
if newDevice.ipAddress != "BLE" && isPlus && isFileAccessEnabled {
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
Expand Down Expand Up @@ -429,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")
Expand Down Expand Up @@ -473,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)
}
Expand Down
91 changes: 91 additions & 0 deletions airsync-mac/Core/Storage/WebDAVManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// 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")
} 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)
}
}
}
6 changes: 5 additions & 1 deletion airsync-mac/Localization/en.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -61,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."
}
10 changes: 10 additions & 0 deletions airsync-mac/Screens/MenubarView/MenubarSegments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
42 changes: 42 additions & 0 deletions airsync-mac/Screens/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
}
Expand Down