From f345ddaff8505a7f70beac64b27b71ba2b362ab1 Mon Sep 17 00:00:00 2001 From: AntonP29 Date: Sat, 21 Mar 2026 21:30:41 -0700 Subject: [PATCH 1/7] Add LocalSend support with service integration and UI updates --- DynamicIsland.xcodeproj/project.pbxproj | 31 +- .../LocalSend.imageset/Contents.json | 21 + .../LocalSend.imageset/localsend.png | Bin 0 -> 66865 bytes DynamicIsland/ContentView.swift | 1 - DynamicIsland/Localizable.xcstrings | 23 + .../components/Shelf/AirDropView.swift | 98 +- .../Shelf/Services/LocalSendService.swift | 1058 +++++++++++++++++ .../Shelf/Services/QuickShareService.swift | 58 +- .../ViewModels/ShelfStateViewModel.swift | 6 + .../Shelf/Views/FileShareView.swift | 207 +++- .../components/Tabs/TabSelectionView.swift | 10 +- .../ExtensionAuthorizationManager.swift | 2 + DynamicIsland/models/Constants.swift | 4 + .../Extensions/ExtensionRPCServer.swift | 25 + .../Extensions/ExtensionRPCService.swift | 258 +++- 15 files changed, 1773 insertions(+), 29 deletions(-) create mode 100644 DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json create mode 100644 DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png create mode 100644 DynamicIsland/components/Shelf/Services/LocalSendService.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 554f95a..4c209ab 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -27,7 +27,6 @@ 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; }; - 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -54,12 +53,18 @@ 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = ""; }; 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; }; 7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; - 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ExtensionEventBridge.swift; path = DynamicIsland/managers/Extensions/ExtensionEventBridge.swift; sourceTree = SOURCE_ROOT; }; F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; }; F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */; + }; 7EA69B1E2F5AE61200EB2D16 /* Exceptions for "Contents" folder in "DynamicIsland" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -94,6 +99,9 @@ }; F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */, + ); path = DynamicIsland; sourceTree = ""; }; @@ -123,14 +131,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 7E07AFF12F181892003A8B1B /* Recovered References */ = { - isa = PBXGroup; - children = ( - 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */, - ); - name = "Recovered References"; - sourceTree = ""; - }; 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = { isa = PBXGroup; children = ( @@ -173,7 +173,6 @@ 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */, F54406E92CD4987A00C7AFD9 /* DynamicIsland */, F54406E82CD4987A00C7AFD9 /* Products */, - 7E07AFF12F181892003A8B1B /* Recovered References */, ); sourceTree = ""; }; @@ -296,6 +295,7 @@ /* Begin PBXShellScriptBuildPhase section */ 7EFAA8AA2F6F12F3000A99E8 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -318,7 +318,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -451,13 +450,11 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; + DEVELOPMENT_TEAM = 5W5BDQQ32W; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -529,7 +526,7 @@ CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 5W5BDQQ32W; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json b/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json new file mode 100644 index 0000000..463ed06 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "localsend.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png b/DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png new file mode 100644 index 0000000000000000000000000000000000000000..5a5f3c18c31ce33e664e8acdae041f419ac11346 GIT binary patch literal 66865 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≥C6^sMlvgF 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

mlRX$aeB!&dGP1{(IsS$#vXs-nyyF+ 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. */ +import AppKit +import Defaults import SwiftUI import UniformTypeIdentifiers @@ -24,6 +26,10 @@ struct AirDropView: View { @State var trigger: UUID = .init() @State var targeting = false + @Default(.quickShareProvider) var quickShareProvider + @StateObject private var quickShareService = QuickShareService.shared + @State private var showShareSettings = false + @State private var isGearHover = false var body: some View { dropArea @@ -40,7 +46,97 @@ struct AirDropView: View { .fill(.white.opacity(0.1)) .opacity(0.5) .clipShape(RoundedRectangle(cornerRadius: 10)) - .overlay { dropLabel } + .overlay { + ZStack { + dropLabel + + // Gear in the bottom-right for quick-share settings + VStack { + Spacer() + HStack { + Spacer() + Button { + quickShareService.ensureDiscovered() + showShareSettings.toggle() + } label: { + Image(systemName: "gearshape") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isGearHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear) + ) + .foregroundColor(isGearHover ? .accentColor : .gray) + .contentShape(Rectangle()) + .onHover { over in isGearHover = over } + .help("Quick Share settings") + } + .buttonStyle(.plain) + .popover(isPresented: $showShareSettings) { + VStack(alignment: .leading, spacing: 8) { + Text("Quick Share") + .font(.headline) + + Picker("Quick Share Service", selection: $quickShareProvider) { + ForEach(quickShareService.availableProviders, id: \.id) { provider in + HStack(spacing: 8) { + Group { + if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "square.and.arrow.up") + } + } + .frame(width: 16, height: 16) + .foregroundColor(.accentColor) + + Text(provider.id) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + .tag(provider.id) + } + } + .pickerStyle(.menu) + .frame(minWidth: 260) + + if let selected = quickShareService.availableProviders.first(where: { $0.id == quickShareProvider }) { + HStack(alignment: .top, spacing: 8) { + Group { + if let imgData = selected.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .frame(width: 20, height: 20) + } else { + Image(systemName: "square.and.arrow.up") + } + } + .foregroundColor(.accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text("Currently: \(selected.id)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + Text("Files dropped here will be shared via this service") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + .padding() + } + .padding(8) + } + } + } + } .aspectRatio(1, contentMode: .fit) .contentShape(Rectangle()) } diff --git a/DynamicIsland/components/Shelf/Services/LocalSendService.swift b/DynamicIsland/components/Shelf/Services/LocalSendService.swift new file mode 100644 index 0000000..62618c4 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/LocalSendService.swift @@ -0,0 +1,1058 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +import Foundation +import Network +import Defaults +import UniformTypeIdentifiers +import Darwin + +struct LocalSendDeviceInfo: Identifiable, Hashable, Sendable { + let id: String + let alias: String + let ip: String + let port: Int + let https: Bool + let model: String? + + var displayName: String { + if let model, !model.isEmpty { + return "\(alias) (\(model))" + } + return alias + } + + var baseURL: String { + let scheme = https ? "https" : "http" + return "\(scheme)://\(ip):\(port)" + } +} + +@MainActor +final class LocalSendService: NSObject, ObservableObject { + static let shared = LocalSendService() + + @Published private(set) var devices: [LocalSendDeviceInfo] = [] + @Published private(set) var isRefreshing = false + @Published private(set) var isSending = false + @Published private(set) var sendProgress: Double = 0 + @Published var selectedDeviceID: String { + didSet { Defaults[.localSendSelectedDeviceID] = selectedDeviceID } + } + + private let multicastGroupHost = "224.0.0.167" + private let defaultPort = 53317 + private var connectionGroup: NWConnectionGroup? + private var registerListener: NWListener? + private var cleanupTask: Task? + private var announceTask: Task? + private var activeRefreshTask: Task? + private var refreshSessionID = UUID() + private var discoveredByID: [String: (device: LocalSendDeviceInfo, lastSeen: Date)] = [:] + private var recentProbeIPs: [String] = [] + private var knownPeerIPs: [String] = [] + private var isStarted = false + + private override init() { + selectedDeviceID = Defaults[.localSendSelectedDeviceID] + super.init() + } + + func startDiscovery() { + guard !isStarted else { return } + isStarted = true + + startRegisterListenerIfNeeded() + + do { + let group = try NWMulticastGroup(for: [ + .hostPort( + host: .init(multicastGroupHost), + port: .init(integerLiteral: NWEndpoint.Port.IntegerLiteralType(defaultPort)) + ), + ]) + let params = NWParameters.udp + params.allowLocalEndpointReuse = true + params.includePeerToPeer = true + + let connectionGroup = NWConnectionGroup(with: group, using: params) + connectionGroup.setReceiveHandler(maximumMessageSize: 65_536) { [weak self] message, content, _ in + guard let content, let self else { return } + Task { @MainActor in + self.handleIncoming(content: content, endpoint: message.remoteEndpoint) + } + } + + connectionGroup.stateUpdateHandler = { _ in } + connectionGroup.start(queue: .global(qos: .utility)) + self.connectionGroup = connectionGroup + + sendAnnouncement() + + cleanupTask = Task { [weak self] in + while let self, !Task.isCancelled { + try? await Task.sleep(nanoseconds: 15_000_000_000) + self.cleanupStale() + } + } + + announceTask = Task { [weak self] in + while let self, !Task.isCancelled { + try? await Task.sleep(nanoseconds: 8_000_000_000) + self.sendAnnouncement() + } + } + } catch { + Logger.log("LocalSend discovery start failed: \(error.localizedDescription)", category: .extensions) + } + } + + func refreshDeviceScan() { + activeRefreshTask?.cancel() + startDiscovery() + + let sessionID = UUID() + refreshSessionID = sessionID + isRefreshing = true + + // Keep listener state and run a LocalSend-like refresh sequence: + // multicast announce burst first, then targeted/fallback HTTP discovery. + activeRefreshTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + + let startedAt = Date() + defer { + if self.refreshSessionID == sessionID { + self.isRefreshing = false + self.activeRefreshTask = nil + } + } + + let burstDelays: [UInt64] = [100_000_000, 500_000_000, 2_000_000_000] + for delay in burstDelays { + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled else { return } + self.sendAnnouncement() + } + + guard !Task.isCancelled else { return } + await self.probeNearbyDevicesDirectly(limit: 20, timeout: 0.14) + + guard !Task.isCancelled else { return } + if !self.hasFreshDiscovery(since: startedAt), + let localIP = self.ownIPv4Address() { + await self.probeLocalSubnetLegacy(localIP: localIP, timeout: 0.12) + } + + guard !Task.isCancelled else { return } + self.pruneUnavailableDevices(since: startedAt) + } + } + + private func pruneUnavailableDevices(since date: Date) { + let previousCount = discoveredByID.count + discoveredByID = discoveredByID.filter { $0.value.lastSeen >= date } + if discoveredByID.count != previousCount { + refreshDevices() + } + } + + private func probeNearbyDevicesDirectly(limit: Int = 12, timeout: TimeInterval = 0.14) async { + let port = defaultPort + let candidates = candidateIPsForActiveProbe(limit: max(1, limit)) + guard !candidates.isEmpty else { return } + let deadline = Date().addingTimeInterval(1.0) + + await withTaskGroup(of: LocalSendDeviceInfo?.self) { group in + let maxConcurrent = 4 + var iterator = candidates.makeIterator() + + for _ in 0 ..< min(maxConcurrent, candidates.count) { + guard let ip = iterator.next() else { break } + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: 0.14) + } + } + + while let result = await group.next() { + if Date() > deadline { + group.cancelAll() + return + } + + guard !Task.isCancelled else { + group.cancelAll() + return + } + + if let device = result { + discoveredByID[device.id] = (device, Date()) + rememberKnownPeerIP(device.ip) + rememberRecentProbeIP(device.ip) + } + + if let ip = iterator.next() { + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + } + } + + refreshDevices() + } + + private func candidateIPsForActiveProbe(limit: Int) -> [String] { + var ordered: [String] = [] + var seen = Set() + + if let selected = devices.first(where: { $0.id == selectedDeviceID })?.ip, + seen.insert(selected).inserted { + ordered.append(selected) + } + + for ip in recentProbeIPs where seen.insert(ip).inserted { + ordered.append(ip) + } + + for ip in discoveredByID.values.map(\.device.ip) where seen.insert(ip).inserted { + ordered.append(ip) + } + + for ip in knownPeerIPs where seen.insert(ip).inserted { + ordered.append(ip) + } + + if ordered.count > limit { + return Array(ordered.prefix(limit)) + } + return ordered + } + + private func probeLocalSubnetLegacy(localIP: String, timeout: TimeInterval) async { + guard isValidIPv4(localIP) else { return } + let parts = localIP.split(separator: ".") + guard parts.count == 4 else { return } + + let prefix = parts.prefix(3).joined(separator: ".") + let host = Int(parts[3]) ?? 0 + let candidates = (1 ... 254) + .filter { $0 != host } + .map { "\(prefix).\($0)" } + + await probeExactIPs(candidates, timeout: timeout, concurrency: 50) + } + + private func probeExactIPs(_ ips: [String], timeout: TimeInterval, concurrency: Int) async { + let port = defaultPort + let unique = Array(NSOrderedSet(array: ips).compactMap { $0 as? String }) + guard !unique.isEmpty else { return } + + await withTaskGroup(of: LocalSendDeviceInfo?.self) { group in + var iterator = unique.makeIterator() + + for _ in 0 ..< min(max(1, concurrency), unique.count) { + guard let ip = iterator.next() else { break } + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + + while let result = await group.next() { + guard !Task.isCancelled else { + group.cancelAll() + return + } + + if let device = result { + discoveredByID[device.id] = (device, Date()) + rememberKnownPeerIP(device.ip) + rememberRecentProbeIP(device.ip) + } + + if let ip = iterator.next() { + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + } + } + + refreshDevices() + } + + private func hasFreshDiscovery(since date: Date) -> Bool { + discoveredByID.values.contains { $0.lastSeen >= date } + } + + private nonisolated static func probeDeviceInfo(at ip: String, port: Int, timeout: TimeInterval) async -> LocalSendDeviceInfo? { + let schemes = ["http", "https"] + let paths = ["/api/localsend/v2/info"] + + for scheme in schemes { + for path in paths { + guard var components = URLComponents(string: "\(scheme)://\(ip):\(port)\(path)") else { continue } + components.queryItems = [ + URLQueryItem(name: "fingerprint", value: "atoll.localsend.bridge"), + ] + guard let url = components.url else { continue } + + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + + do { + let (data, response) = try await probeSession.data(for: request) + guard let http = response as? HTTPURLResponse, + (200 ... 299).contains(http.statusCode), + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String, + fingerprint != "atoll.localsend.bridge" + else { + continue + } + + let model = json["deviceModel"] as? String + let usesHTTPS = (json["protocol"] as? String) == "https" || scheme == "https" + + return LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: ip, + port: port, + https: usesHTTPS, + model: model + ) + } catch { + continue + } + } + } + + return nil + } + + private nonisolated static let probeSession: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 0.2 + config.timeoutIntervalForResource = 0.2 + config.waitsForConnectivity = false + return URLSession(configuration: config, delegate: LocalSendTLSDelegate(), delegateQueue: nil) + }() + + private func rememberRecentProbeIP(_ ip: String) { + guard isValidIPv4(ip) else { return } + recentProbeIPs.removeAll { $0 == ip } + recentProbeIPs.insert(ip, at: 0) + if recentProbeIPs.count > 12 { + recentProbeIPs = Array(recentProbeIPs.prefix(12)) + } + } + + private func rememberKnownPeerIP(_ ip: String) { + guard isValidIPv4(ip) else { return } + knownPeerIPs.removeAll { $0 == ip } + knownPeerIPs.insert(ip, at: 0) + if knownPeerIPs.count > 48 { + knownPeerIPs = Array(knownPeerIPs.prefix(48)) + } + } + + private func arpNeighborIPs() -> [String] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/arp") + process.arguments = ["-an"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return [] } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return [] } + + let regex = try NSRegularExpression(pattern: #"\((\d{1,3}(?:\.\d{1,3}){3})\)"#) + let nsrange = NSRange(output.startIndex..() + for match in matches { + guard match.numberOfRanges > 1, + let range = Range(match.range(at: 1), in: output) + else { continue } + let ip = String(output[range]) + guard isValidIPv4(ip) else { continue } + if ip.hasPrefix("169.254.") { continue } + if ip == "255.255.255.255" { continue } + + if let ownPrefix { + let ipPrefix = ip.split(separator: ".").prefix(3).joined(separator: ".") + guard ipPrefix == ownPrefix else { continue } + } + + if seen.insert(ip).inserted { + ips.append(ip) + } + } + + return ips + } catch { + return [] + } + } + + private func localSubnetSweepIPs(radius: Int) -> [String] { + guard let own = ownIPv4Address(), isValidIPv4(own) else { return [] } + let parts = own.split(separator: ".").compactMap { Int($0) } + guard parts.count == 4 else { return [] } + + let prefix = "\(parts[0]).\(parts[1]).\(parts[2])" + let myHost = parts[3] + let start = max(1, myHost - radius) + let end = min(254, myHost + radius) + + return (start ... end) + .filter { $0 != myHost } + .map { "\(prefix).\($0)" } + } + + private func ownIPv4Address() -> String? { + var ifaddr: UnsafeMutablePointer? + guard getifaddrs(&ifaddr) == 0, let first = ifaddr else { return nil } + defer { freeifaddrs(ifaddr) } + + var ptr: UnsafeMutablePointer? = first + while let current = ptr { + defer { ptr = current.pointee.ifa_next } + + let family = current.pointee.ifa_addr.pointee.sa_family + let flags = Int32(current.pointee.ifa_flags) + guard family == UInt8(AF_INET), + (flags & IFF_UP) == IFF_UP, + (flags & IFF_LOOPBACK) == 0 + else { continue } + + var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + var addr = current.pointee.ifa_addr.pointee + let result = getnameinfo( + &addr, + socklen_t(current.pointee.ifa_addr.pointee.sa_len), + &hostBuffer, + socklen_t(hostBuffer.count), + nil, + 0, + NI_NUMERICHOST + ) + guard result == 0 else { continue } + + let ip = String(cString: hostBuffer) + if isValidIPv4(ip) { + return ip + } + } + + return nil + } + + private func isValidIPv4(_ ip: String) -> Bool { + let parts = ip.split(separator: ".") + guard parts.count == 4 else { return false } + for p in parts { + guard let v = Int(p), (0 ... 255).contains(v) else { return false } + } + return true + } + + func send(items: [Any]) async throws { + let startedAt = Date() + isSending = true + sendProgress = 0 + + do { + guard let target = devices.first(where: { $0.id == selectedDeviceID }) ?? devices.first else { + throw LocalSendServiceError.noDeviceSelected + } + + let files = try await buildTransferFiles(from: items) + guard !files.isEmpty else { throw LocalSendServiceError.noTransferableItems } + + let prepare = try await prepareUpload(files: files, to: target) + guard !prepare.fileTokens.isEmpty else { + sendProgress = 1 + await finishSending(startedAt: startedAt) + return + } + + let uploads: [(TransferFile, String)] = files.compactMap { file in + guard let token = prepare.fileTokens[file.id] else { return nil } + return (file, token) + } + + // Compute total bytes to provide smooth overall progress + let totalBytes = uploads.reduce(Int64(0)) { acc, entry in acc + Int64(entry.0.data.count) } + var bytesCompleted: Int64 = 0 + + for (index, entry) in uploads.enumerated() { + let file = entry.0 + let fileSize = Int64(file.data.count) + + try await upload(file: file, sessionID: prepare.sessionID, token: entry.1, to: target) { fileFraction in + if totalBytes > 0 { + let currentSent = Int64(Double(fileSize) * fileFraction) + let overall = Double(bytesCompleted + currentSent) / Double(totalBytes) + Task { @MainActor in self.sendProgress = overall } + } else { + // Fallback to file-index-based progress when sizes unknown + Task { @MainActor in self.sendProgress = Double(index) / Double(max(uploads.count, 1)) } + } + } + + bytesCompleted += fileSize + } + + sendProgress = 1 + await finishSending(startedAt: startedAt) + } catch { + await finishSending(startedAt: startedAt) + throw error + } + } + + private func finishSending(startedAt: Date) async { + let elapsed = Date().timeIntervalSince(startedAt) + let minimumVisibleDuration: TimeInterval = 0.8 + if elapsed < minimumVisibleDuration { + let remaining = minimumVisibleDuration - elapsed + try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) + } + isSending = false + sendProgress = 0 + } + + private func sendAnnouncement() { + let payload: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "port": defaultPort, + // Use HTTP here so LocalSend peers can quickly fail over to UDP response + // when Atoll is not serving LocalSend register endpoint. + "protocol": "http", + "download": false, + "announcement": true, + "announce": true, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + connectionGroup?.send(content: data, completion: { _ in }) + } + + private func startRegisterListenerIfNeeded() { + guard registerListener == nil else { return } + + do { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + params.includePeerToPeer = true + + let listener = try NWListener( + using: params, + on: NWEndpoint.Port(integerLiteral: NWEndpoint.Port.IntegerLiteralType(defaultPort)) + ) + + listener.stateUpdateHandler = { state in + if case let .failed(error) = state { + Task { @MainActor [weak self] in + Logger.log("LocalSend register listener failed: \(error.localizedDescription)", category: .extensions) + self?.registerListener = nil + } + } + } + + listener.newConnectionHandler = { [weak self] connection in + Task { @MainActor in + self?.handleRegisterConnection(connection) + } + } + + listener.start(queue: .global(qos: .utility)) + registerListener = listener + } catch { + Logger.log("LocalSend register listener start failed: \(error.localizedDescription)", category: .extensions) + } + } + + private func handleRegisterConnection(_ connection: NWConnection) { + connection.start(queue: .global(qos: .utility)) + connection.receive(minimumIncompleteLength: 1, maximumLength: 131_072) { [weak self] content, _, _, _ in + guard let self, let data = content, !data.isEmpty else { + connection.cancel() + return + } + + Task { @MainActor in + let response = self.processRegisterRequest(data: data, from: connection.endpoint) + connection.send(content: response, completion: .contentProcessed { _ in + connection.cancel() + }) + } + } + } + + private func processRegisterRequest(data: Data, from endpoint: NWEndpoint) -> Data { + guard let request = String(data: data, encoding: .utf8) else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let parts = request.components(separatedBy: "\r\n\r\n") + guard let head = parts.first, + let firstLine = head.components(separatedBy: "\r\n").first + else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let tokens = firstLine.split(separator: " ") + guard tokens.count >= 2 else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let method = String(tokens[0]).uppercased() + let rawPath = String(tokens[1]) + let path = rawPath.components(separatedBy: "?").first ?? rawPath + + if method == "POST", path == "/api/localsend/v2/register" || path == "/api/localsend/v3/register" { + let callerIP = endpointIPv4(endpoint) + let bodyText = parts.dropFirst().joined(separator: "\r\n\r\n") + let bodyData = bodyText.data(using: .utf8) + let json = bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + + if let json, + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String, + let callerIP, + fingerprint != "atoll.localsend.bridge" { + let device = LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: callerIP, + port: (json["port"] as? Int) ?? defaultPort, + https: (json["protocol"] as? String) == "https", + model: json["deviceModel"] as? String + ) + discoveredByID[fingerprint] = (device, Date()) + rememberKnownPeerIP(callerIP) + rememberRecentProbeIP(callerIP) + refreshDevices() + } else if let callerIP { + // Be tolerant to partial HTTP payloads: still ACK register and probe caller directly. + Task { @MainActor [weak self] in + guard let self else { return } + if let device = await Self.probeDeviceInfo(at: callerIP, port: defaultPort, timeout: 0.35) { + self.discoveredByID[device.id] = (device, Date()) + self.rememberKnownPeerIP(device.ip) + self.rememberRecentProbeIP(device.ip) + self.refreshDevices() + } + } + } + + let responseJSON: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "token": "atoll.localsend.bridge", + "fingerprint": "atoll.localsend.bridge", + "download": false, + "hasWebInterface": false, + ] + return httpResponse(status: 200, json: responseJSON) + } + + if method == "GET", path == "/api/localsend/v2/info" { + let responseJSON: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "port": defaultPort, + "protocol": "http", + "download": false, + ] + return httpResponse(status: 200, json: responseJSON) + } + + return httpResponse(status: 404, json: ["error": "not-found"]) + } + + private func endpointIPv4(_ endpoint: NWEndpoint) -> String? { + guard case let .hostPort(host, _) = endpoint else { return nil } + let ip = host.debugDescription.replacingOccurrences(of: "\"", with: "") + return isValidIPv4(ip) ? ip : nil + } + + private func httpResponse(status: Int, json: [String: Any]) -> Data { + let body = (try? JSONSerialization.data(withJSONObject: json)) ?? Data("{}".utf8) + let headers = [ + "HTTP/1.1 \(status) \(status == 200 ? "OK" : "Error")", + "Content-Type: application/json", + "Content-Length: \(body.count)", + "Connection: close", + "", + "", + ].joined(separator: "\r\n") + + var response = Data(headers.utf8) + response.append(body) + return response + } + + private func handleIncoming(content: Data, endpoint: NWEndpoint?) { + guard let json = try? JSONSerialization.jsonObject(with: content) as? [String: Any], + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String + else { return } + + if fingerprint == "atoll.localsend.bridge" { return } + + let ip: String + if let announced = json["ip"] as? String { + ip = announced + } else if case let .hostPort(host, _) = endpoint { + ip = host.debugDescription.replacingOccurrences(of: "\"", with: "") + } else { + return + } + + let port = (json["port"] as? Int) ?? defaultPort + let https = (json["protocol"] as? String) == "https" + let model = json["deviceModel"] as? String + + let device = LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: ip, + port: port, + https: https, + model: model + ) + + discoveredByID[fingerprint] = (device, Date()) + rememberKnownPeerIP(ip) + rememberRecentProbeIP(ip) + refreshDevices() + } + + private func cleanupStale() { + let cutoff = Date().addingTimeInterval(-30) + discoveredByID = discoveredByID.filter { $0.value.lastSeen > cutoff } + refreshDevices() + } + + private func refreshDevices() { + devices = discoveredByID.values.map(\.device).sorted { $0.alias.localizedCaseInsensitiveCompare($1.alias) == .orderedAscending } + if !devices.contains(where: { $0.id == selectedDeviceID }), let first = devices.first { + selectedDeviceID = first.id + } + } + + private struct TransferFile { + let id: String + let name: String + let mimeType: String + let data: Data + } + + private func buildTransferFiles(from items: [Any]) async throws -> [TransferFile] { + var result: [TransferFile] = [] + for item in items { + if let url = item as? URL, url.isFileURL { + if let data = try? Data(contentsOf: url) { + result.append(TransferFile( + id: UUID().uuidString, + name: url.lastPathComponent, + mimeType: preferredTransferMimeType(for: url), + data: data + )) + } + } else if let url = item as? URL { + let string = url.absoluteString + result.append(TransferFile( + id: UUID().uuidString, + name: "link.url", + mimeType: "text/uri-list", + data: Data(string.utf8) + )) + } else if let text = item as? String { + result.append(TransferFile( + id: UUID().uuidString, + name: "text.txt", + mimeType: "text/plain", + data: Data(text.utf8) + )) + } + } + return result + } + + private func preferredTransferMimeType(for url: URL) -> String { + let ext = url.pathExtension.lowercased() + if ext == "svg" { + // Some LocalSend receivers try gallery/image-specific write paths for SVG and fail. + return "application/octet-stream" + } + return url.mimeType ?? "application/octet-stream" + } + + private func prepareUpload(files: [TransferFile], to device: LocalSendDeviceInfo) async throws -> (sessionID: String, fileTokens: [String: String]) { + var filesMap: [String: Any] = [:] + for file in files { + filesMap[file.id] = [ + "id": file.id, + "fileName": file.name, + "size": file.data.count, + "fileType": file.mimeType, + ] + } + + let payload: [String: Any] = [ + "info": [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "token": "atoll.localsend.bridge", + "port": defaultPort, + "protocol": device.https ? "https" : "http", + "download": false, + ], + "files": filesMap, + ] + + var lastError: Error? + for baseURL in candidateBaseURLs(for: device) { + do { + return try await prepareUpload(payload: payload, baseURL: baseURL) + } catch { + lastError = error + Logger.log("LocalSend prepare-upload failed via \(baseURL): \(error.localizedDescription)", category: .extensions) + if !shouldRetryAcrossSchemes(error) { + throw error + } + } + } + + throw lastError ?? LocalSendServiceError.invalidResponse + } + + private func prepareUpload(payload: [String: Any], baseURL: String) async throws -> (sessionID: String, fileTokens: [String: String]) { + guard let url = URL(string: "\(baseURL)/api/localsend/v2/prepare-upload") else { + throw LocalSendServiceError.invalidTarget + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let (data, response) = try await trustedSession.data(for: request) + guard let http = response as? HTTPURLResponse else { throw LocalSendServiceError.invalidResponse } + + if http.statusCode == 204 { + return ("", [:]) + } + guard (200 ... 299).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) + throw LocalSendServiceError.server(status: http.statusCode, body: body) + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let sessionID = json["sessionId"] as? String, + let rawFileTokens = json["files"] as? [String: Any] + else { throw LocalSendServiceError.invalidResponse } + + var fileTokens: [String: String] = [:] + for (k, v) in rawFileTokens { + if let s = v as? String { + fileTokens[k] = s + } + } + + return (sessionID, fileTokens) + } + + private func upload(file: TransferFile, sessionID: String, token: String, to device: LocalSendDeviceInfo, progress: @escaping (Double) -> Void) async throws { + var lastError: Error? + for baseURL in candidateBaseURLs(for: device) { + do { + try await upload(file: file, sessionID: sessionID, token: token, baseURL: baseURL, progress: progress) + return + } catch { + lastError = error + Logger.log("LocalSend upload failed via \(baseURL) for \(file.name): \(error.localizedDescription)", category: .extensions) + if !shouldRetryAcrossSchemes(error) { + throw error + } + } + } + throw lastError ?? LocalSendServiceError.invalidResponse + } + + private func upload(file: TransferFile, sessionID: String, token: String, baseURL: String, progress: @escaping (Double) -> Void) async throws { + var components = URLComponents(string: "\(baseURL)/api/localsend/v2/upload") + components?.queryItems = [ + URLQueryItem(name: "sessionId", value: sessionID), + URLQueryItem(name: "fileId", value: file.id), + URLQueryItem(name: "token", value: token), + ] + + guard let url = components?.url else { throw LocalSendServiceError.invalidTarget } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(file.mimeType, forHTTPHeaderField: "Content-Type") + request.setValue("\(file.data.count)", forHTTPHeaderField: "Content-Length") + + // Use uploadTask to receive per-byte progress via delegate + let delegate = UploadProgressDelegate { sent, expected in + guard expected > 0 else { return } + let fraction = Double(sent) / Double(expected) + progress(fraction) + } + + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + + let (data, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in + let task = session.uploadTask(with: request, from: file.data) { data, response, error in + if let error = error { + continuation.resume(throwing: error) + return + } + guard let data = data, let response = response else { + continuation.resume(throwing: LocalSendServiceError.invalidResponse) + return + } + continuation.resume(returning: (data, response)) + } + task.resume() + } + + session.finishTasksAndInvalidate() + + guard let http = response as? HTTPURLResponse, (200 ... 299).contains(http.statusCode) else { + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + let body = String(data: data, encoding: .utf8) + throw LocalSendServiceError.server(status: status, body: body) + } + } + + private func candidateBaseURLs(for device: LocalSendDeviceInfo) -> [String] { + let primary = device.baseURL + let alternateScheme = device.https ? "http" : "https" + let alternate = "\(alternateScheme)://\(device.ip):\(device.port)" + return primary == alternate ? [primary] : [primary, alternate] + } + + private func shouldRetryAcrossSchemes(_ error: Error) -> Bool { + if case LocalSendServiceError.server = error { + // A peer responded with a concrete HTTP error; fallback to another scheme is usually noise. + return false + } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + let code = URLError.Code(rawValue: nsError.code) + switch code { + case .timedOut, .networkConnectionLost, .notConnectedToInternet, + .cannotFindHost, .cannotConnectToHost, .secureConnectionFailed, + .dnsLookupFailed: + return true + default: + return false + } + } + + return false + } + + private lazy var trustedSession: URLSession = { + URLSession(configuration: .default, delegate: LocalSendTLSDelegate(), delegateQueue: nil) + }() +} + +private class LocalSendTLSDelegate: NSObject, URLSessionDelegate { + func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + if let trust = challenge.protectionSpace.serverTrust { + return (.useCredential, URLCredential(trust: trust)) + } + return (.performDefaultHandling, nil) + } +} + +private final class UploadProgressDelegate: LocalSendTLSDelegate, URLSessionTaskDelegate { + private let onProgress: (Int64, Int64) -> Void + + init(onProgress: @escaping (Int64, Int64) -> Void) { + self.onProgress = onProgress + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + Task { @MainActor in + onProgress(totalBytesSent, totalBytesExpectedToSend) + } + } +} + +private extension URL { + var mimeType: String? { + guard !pathExtension.isEmpty, + let type = UTType(filenameExtension: pathExtension) + else { return nil } + return type.preferredMIMEType + } +} + +enum LocalSendServiceError: LocalizedError { + case noDeviceSelected + case noTransferableItems + case invalidTarget + case invalidResponse + case server(status: Int, body: String?) + + var errorDescription: String? { + switch self { + case .noDeviceSelected: + return "No LocalSend device selected" + case .noTransferableItems: + return "No transferable files or text found" + case .invalidTarget: + return "Invalid LocalSend target" + case .invalidResponse: + return "Invalid response from LocalSend peer" + case .server(let status, let body): + if let body, !body.isEmpty { + return "LocalSend peer error (\(status)): \(body)" + } + return "LocalSend peer error (\(status))" + } + } +} \ No newline at end of file diff --git a/DynamicIsland/components/Shelf/Services/QuickShareService.swift b/DynamicIsland/components/Shelf/Services/QuickShareService.swift index 548402f..b77f925 100644 --- a/DynamicIsland/components/Shelf/Services/QuickShareService.swift +++ b/DynamicIsland/components/Shelf/Services/QuickShareService.swift @@ -129,6 +129,10 @@ class QuickShareService: ObservableObject { providers.insert(ad, at: 0) } + if !providers.contains(where: { $0.id == "LocalSend" }) { + providers.insert(QuickShareProvider(id: "LocalSend", imageData: nil, supportsRawText: true), at: min(1, providers.count)) + } + // System Share Menu fallback if !providers.contains(where: { $0.id == "System Share Menu" }) { providers.append(QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)) @@ -137,11 +141,36 @@ class QuickShareService: ObservableObject { return (providers, services) }.value + var providers = result.providers + if let idx = providers.firstIndex(where: { $0.id == "LocalSend" }) { + providers[idx].imageData = localSendIconData() + } + self.cachedServices = result.services - self.availableProviders = result.providers + self.availableProviders = providers self.hasDiscovered = true self.discoveryTask = nil } + + private func localSendIconData() -> Data? { + guard let icon = NSImage(named: NSImage.Name("LocalSend")) else { + return nil + } + let thumbSize = NSSize(width: 32, height: 32) + let thumb = NSImage(size: thumbSize) + thumb.lockFocus() + icon.draw(in: NSRect(origin: .zero, size: thumbSize), + from: NSRect(origin: .zero, size: icon.size), + operation: .copy, + fraction: 1.0) + thumb.unlockFocus() + + guard let tiff = thumb.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) + else { return nil } + + return bitmap.representation(using: .png, properties: [:]) + } // MARK: - File Picker @MainActor @@ -154,6 +183,9 @@ class QuickShareService: ObservableObject { isPickerOpen = true SharingStateManager.shared.beginInteraction() + // Improve interaction in dev/test builds where app activation can be flaky. + NSApp.activate(ignoringOtherApps: true) + let panel = NSOpenPanel() panel.allowsMultipleSelection = true panel.canChooseDirectories = true @@ -174,8 +206,12 @@ class QuickShareService: ObservableObject { } } - let response = panel.runModal() - completion(response) + if let window = view?.window { + panel.beginSheetModal(for: window, completionHandler: completion) + } else { + let response = panel.runModal() + completion(response) + } } // MARK: - Sharing @@ -187,6 +223,21 @@ class QuickShareService: ObservableObject { // Start security-scoped access for all file URLs sharingAccessingURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } + if provider.id == "LocalSend" { + SharingStateManager.shared.beginInteraction() + defer { + SharingStateManager.shared.endInteraction() + } + + do { + try await LocalSendService.shared.send(items: items) + } catch { + NSAlert.popError(error) + } + stopSharingAccessingURLs() + return + } + // Setup lifecycle delegate to keep notch open during picker/service let delegate = SharingStateManager.shared.makeDelegate { [weak self] in self?.lifecycleDelegate = nil @@ -210,6 +261,7 @@ class QuickShareService: ObservableObject { } private func stopSharingAccessingURLs() { + guard !sharingAccessingURLs.isEmpty else { return } NSLog("Stopping sharing access to URLs") for url in sharingAccessingURLs { url.stopAccessingSecurityScopedResource() diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift index ab685ff..e1972f8 100644 --- a/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift @@ -49,19 +49,25 @@ final class ShelfStateViewModel: ObservableObject { var merged = items // Deduplicate by identityKey while preserving order (existing first) var seen: Set = Set(merged.map { $0.identityKey }) + var addedIDs: [String] = [] for it in newItems { let key = it.identityKey if !seen.contains(key) { merged.append(it) seen.insert(key) + addedIDs.append(it.id.uuidString) } } items = merged + if !addedIDs.isEmpty { + ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: addedIDs, action: "added") + } } func remove(_ item: ShelfItem) { item.cleanupStoredData() items.removeAll { $0.id == item.id } + ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: [item.id.uuidString], action: "removed") } func updateBookmark(for item: ShelfItem, bookmark: Data) { diff --git a/DynamicIsland/components/Shelf/Views/FileShareView.swift b/DynamicIsland/components/Shelf/Views/FileShareView.swift index 9c0075e..5795f16 100644 --- a/DynamicIsland/components/Shelf/Views/FileShareView.swift +++ b/DynamicIsland/components/Shelf/Views/FileShareView.swift @@ -28,7 +28,11 @@ import UniformTypeIdentifiers struct FileShareView: View { @EnvironmentObject private var vm: DynamicIslandViewModel @StateObject private var quickShare = QuickShareService.shared + @StateObject private var localSend = LocalSendService.shared @Default(.quickShareProvider) var quickShareProvider: String + @State private var showQuickSharePopover = false + @State private var isGearHover = false + @State private var autoCloseToken = UUID() @State private var hostView: NSView? @State private var interactionNonce: UUID = .init() @@ -38,10 +42,17 @@ struct FileShareView: View { quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true) } + private var notchToggleProviders: [QuickShareProvider] { + quickShare.availableProviders.filter { $0.id == "AirDrop" || $0.id == "LocalSend" } + } + var body: some View { dropArea .background(NSViewHost(view: $hostView)) - .onAppear { quickShare.ensureDiscovered() } + .onAppear { + quickShare.ensureDiscovered() + localSend.startDiscovery() + } .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in interactionNonce = .init() vm.dropEvent = true @@ -49,8 +60,10 @@ struct FileShareView: View { return true } .onTapGesture { - Task { - await handleClick() + guard quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) != nil else { return } + // Only open picker on taps when AirDrop or LocalSend is selected + if quickShareProvider == "AirDrop" || quickShareProvider == "LocalSend" { + Task { await handleClick() } } } } @@ -85,12 +98,16 @@ struct FileShareView: View { if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFit() + .frame(width: 34, height: 34) + .clipped() } else { Image(systemName: "square.and.arrow.up") + .resizable() + .scaledToFit() + .frame(width: 34, height: 34) } } - .frame(width: 34, height: 34) .foregroundStyle( vm.dropZoneTargeting ? Color.accentColor : Color.gray ) @@ -104,11 +121,159 @@ struct FileShareView: View { .font(.system(.headline, design: .rounded)) .foregroundColor(.white.opacity(0.8)) .multilineTextAlignment(.center) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity) + // Share mode selector moved to the notch tab gear icon + + if selectedProvider.id == "LocalSend" { + HStack(spacing: 8) { + Menu { + if localSend.devices.isEmpty { + Text("No devices found") + } else { + ForEach(localSend.devices) { device in + Button(device.displayName) { + localSend.selectedDeviceID = device.id + } + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: "iphone.and.arrow.forward") + .font(.system(size: 11, weight: .semibold)) + Text(localSend.devices.first(where: { $0.id == localSend.selectedDeviceID })?.displayName ?? "Select device") + .font(.system(size: 11, weight: .medium)) + .lineLimit(1) + } + .foregroundStyle(.white.opacity(0.9)) + } + + Button { + localSend.refreshDeviceScan() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.9)) + .rotationEffect(.degrees(localSend.isRefreshing ? 360 : 0)) + .animation( + localSend.isRefreshing + ? .linear(duration: 0.85).repeatForever(autoreverses: false) + : .easeOut(duration: 0.2), + value: localSend.isRefreshing + ) + } + .buttonStyle(.plain) + .disabled(localSend.isRefreshing) + .help("Refresh devices") + } + + } + } .padding(18) .frame(maxWidth: .infinity) + + // Gear pinned to top-left corner of quick share box + VStack { + HStack { + Button { + // Ensure notch stays open immediately when opening the selector + vm.setAutoCloseSuppression(true, token: autoCloseToken) + quickShare.ensureDiscovered() + showQuickSharePopover.toggle() + } label: { + Image(systemName: "gear") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 14, height: 14) + .padding(8) + .background(RoundedRectangle(cornerRadius: 8).fill(isGearHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear)) + .foregroundColor(isGearHover ? .accentColor : .gray) + } + .buttonStyle(PlainButtonStyle()) + .onHover { hovering in + isGearHover = hovering + vm.setAutoCloseSuppression(hovering, token: autoCloseToken) + } + .popover(isPresented: $showQuickSharePopover, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 8) { + Text("Quick Share") + .font(.headline) + + Picker("Quick Share Service", selection: $quickShareProvider) { + ForEach(quickShare.availableProviders, id: \.id) { provider in + HStack(spacing: 8) { + Group { + if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "square.and.arrow.up") + } + } + .frame(width: 16, height: 16) + .foregroundColor(.accentColor) + + Text(provider.id) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + .fixedSize(horizontal: true, vertical: false) + } + .tag(provider.id) + } + } + .pickerStyle(.menu) + .frame(minWidth: 260) + + if let selected = quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) { + HStack(alignment: .top, spacing: 8) { + Group { + if let imgData = selected.imageData, let nsImg = NSImage(data: imgData) { + Image(nsImage: nsImg) + .resizable() + .frame(width: 20, height: 20) + } else { + Image(systemName: "square.and.arrow.up") + } + } + .foregroundColor(.accentColor) + + VStack(alignment: .leading, spacing: 2) { + Text("Currently: \(selected.id)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .layoutPriority(1) + .fixedSize(horizontal: true, vertical: false) + Text("Files shared from the shelf will use this service") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + .padding() + .onAppear { vm.setAutoCloseSuppression(true, token: autoCloseToken) } + .onDisappear { + // Delay clearing suppression slightly so the close click doesn't immediately close the notch + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + vm.setAutoCloseSuppression(false, token: autoCloseToken) + } + } + .onHover { hovering in vm.setAutoCloseSuppression(hovering, token: autoCloseToken) } + } + .padding(.leading, 8) + .padding(.top, 8) + + Spacer() + } + Spacer() + } // Loading overlay if isProcessing || quickShare.isPickerOpen { @@ -120,6 +285,38 @@ struct FileShareView: View { .scaleEffect(0.8) ) } + + if selectedProvider.id == "LocalSend" && localSend.isSending { + VStack { + HStack { + Spacer() + ZStack { + Circle() + .stroke(Color.white.opacity(0.16), lineWidth: 3) + + Circle() + .trim(from: 0, to: min(max(localSend.sendProgress, 0), 1)) + .stroke( + Color.white, + style: StrokeStyle(lineWidth: 3, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: localSend.sendProgress) + + if localSend.sendProgress > 0.99 { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.white) + } + } + .frame(width: 24, height: 24) + .padding(.top, 10) + .padding(.trailing, 10) + } + Spacer() + } + .transition(.opacity) + } } .contentShape(RoundedRectangle(cornerRadius: 12)) } diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index 03a0d09..33939fc 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -23,6 +23,7 @@ import AtollExtensionKit import SwiftUI import Defaults +import AppKit struct TabModel: Identifiable { let id: String @@ -45,6 +46,9 @@ struct TabModel: Identifiable { struct TabSelectionView: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared + @StateObject private var quickShareService = QuickShareService.shared + @Default(.quickShareProvider) private var quickShareProvider + @State private var showQuickSharePopover = false @Default(.enableTimerFeature) var enableTimerFeature @Default(.enableStatsFeature) var enableStatsFeature @Default(.enableColorPickerFeature) var enableColorPickerFeature @@ -106,9 +110,11 @@ struct TabSelectionView: View { } var body: some View { HStack(spacing: 0) { - ForEach(tabs) { tab in + ForEach(Array(tabs.enumerated()), id: \.element.id) { idx, tab in let isSelected = isSelected(tab) let activeAccent = tab.accentColor ?? .white + + // Render the tab button TabButton(label: tab.label, icon: tab.icon, selected: isSelected) { if tab.view == .extensionExperience { coordinator.selectedExtensionExperienceID = tab.experienceID @@ -130,6 +136,8 @@ struct TabSelectionView: View { .hidden() } } + + } } .animation(.smooth(duration: 0.3), value: coordinator.currentView) diff --git a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift index 2c3501c..c4ca4b6 100644 --- a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift @@ -111,6 +111,8 @@ final class ExtensionAuthorizationManager: ObservableObject { record.widgetTimestamps.append(now) case .notchExperiences: record.notchExperienceTimestamps.append(now) + case .fileSharing: + record.activityTimestamps.append(now) } record.activityTimestamps = flushOldTimestamps(record.activityTimestamps) record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps) diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 8ae20b6..f04f6da 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -143,12 +143,14 @@ enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializa case liveActivities case lockScreenWidgets case notchExperiences + case fileSharing var displayName: String { switch self { case .liveActivities: return "Live Activities" case .lockScreenWidgets: return "Lock Screen Widgets" case .notchExperiences: return "Notch Experiences" + case .fileSharing: return "File Sharing" } } } @@ -885,6 +887,7 @@ extension Defaults.Keys { static let dynamicShelf = Key("dynamicShelf", default: true) static let openShelfByDefault = Key("openShelfByDefault", default: true) static let quickShareProvider = Key("quickShareProvider", default: "AirDrop") + static let localSendSelectedDeviceID = Key("localSendSelectedDeviceID", default: "") static let copyOnDrag = Key("copyOnDrag", default: false) static let autoRemoveShelfItems = Key("autoRemoveShelfItems", default: false) static let expandedDragDetection = Key("expandedDragDetection", default: true) @@ -1002,6 +1005,7 @@ extension Defaults.Keys { static let extensionLiveActivityCapacity = Key("extensionLiveActivityCapacity", default: 4) static let extensionLockScreenWidgetCapacity = Key("extensionLockScreenWidgetCapacity", default: 4) static let extensionNotchExperienceCapacity = Key("extensionNotchExperienceCapacity", default: 2) + static let enableExtensionFileSharing = Key("enableExtensionFileSharing", default: true) // MARK: Keyboard Shortcuts static let enableShortcuts = Key("enableShortcuts", default: true) diff --git a/DynamicIsland/services/Extensions/ExtensionRPCServer.swift b/DynamicIsland/services/Extensions/ExtensionRPCServer.swift index 13a9dd2..c1bc1b5 100644 --- a/DynamicIsland/services/Extensions/ExtensionRPCServer.swift +++ b/DynamicIsland/services/Extensions/ExtensionRPCServer.swift @@ -29,6 +29,7 @@ final class ExtensionRPCServer { private var listener: NWListener? private var connections: [UUID: RPCClientConnection] = [:] + private var shelfSubscribers: Set = [] // bundleIdentifiers subscribed to shelf events private let port: UInt16 = 9020 private let queue = DispatchQueue(label: "com.ebullioscopic.Atoll.rpc.server", qos: .userInitiated) private let decoder = JSONDecoder() @@ -78,6 +79,7 @@ final class ExtensionRPCServer { conn.connection.cancel() } connections.removeAll() + shelfSubscribers.removeAll() logDiagnostics("RPC server stopped") } @@ -127,6 +129,29 @@ final class ExtensionRPCServer { ) } + // MARK: - Shelf Event Subscriptions + + func registerShelfSubscription(for bundleIdentifier: String) { + shelfSubscribers.insert(bundleIdentifier) + logDiagnostics("Registered shelf subscription for \(bundleIdentifier)") + } + + func notifyShelfItemsChanged(itemIDs: [String], action: String) { + guard !shelfSubscribers.isEmpty else { return } + let params: [String: RPCValue] = [ + "action": .string(action), + "itemIDs": .array(itemIDs.map { .string($0) }) + ] + for subscriber in shelfSubscribers { + sendNotification( + to: subscriber, + method: "atoll.shelfItemsDidChange", + params: params + ) + } + logDiagnostics("Notified \(shelfSubscribers.count) subscriber(s) of shelf change (\(action), \(itemIDs.count) items)") + } + // MARK: - Connection Handling private func handleListenerState(_ state: NWListener.State) { diff --git a/DynamicIsland/services/Extensions/ExtensionRPCService.swift b/DynamicIsland/services/Extensions/ExtensionRPCService.swift index 383011e..a72757a 100644 --- a/DynamicIsland/services/Extensions/ExtensionRPCService.swift +++ b/DynamicIsland/services/Extensions/ExtensionRPCService.swift @@ -17,8 +17,10 @@ */ import Foundation +import AppKit import Defaults import AtollExtensionKit +import UniformTypeIdentifiers /// Handles JSON-RPC method calls for a single WebSocket connection. /// Mirrors the functionality of `ExtensionXPCService` but uses JSON-RPC transport. @@ -94,6 +96,25 @@ final class ExtensionRPCService { case "atoll.dismissNotchExperience": result = handleDismissNotchExperience(params: request.params, id: request.id) + // MARK: File Sharing + case "atoll.getShelfItems": + result = handleGetShelfItems(params: request.params, id: request.id) + + case "atoll.getShelfItemData": + result = handleGetShelfItemData(params: request.params, id: request.id) + + case "atoll.showFilePicker": + result = handleShowFilePicker(params: request.params, id: request.id) + + case "atoll.shareShelfItems": + result = handleShareShelfItems(params: request.params, id: request.id) + + case "atoll.addFilesToShelf": + result = handleAddFilesToShelf(params: request.params, id: request.id) + + case "atoll.subscribeShelfEvents": + result = handleSubscribeShelfEvents(params: request.params, id: request.id) + default: result = RPCErrorResponse( error: RPCErrorObject(code: RPCErrorCode.methodNotFound, message: "Method not found: \(request.method)"), @@ -327,7 +348,7 @@ final class ExtensionRPCService { case .duplicateIdentifier: code = RPCErrorCode.descriptorInvalid } return RPCErrorResponse( - error: RPCErrorObject(code: code, message: error.localizedDescription ?? "Unknown error"), + error: RPCErrorObject(code: code, message: error.localizedDescription), id: id ) } @@ -337,6 +358,232 @@ final class ExtensionRPCService { Logger.log(message, category: .extensions) } + // MARK: - File Sharing Authorization Check + + private func checkFileSharingAuthorization(id: String) -> RPCErrorResponse? { + guard Defaults[.enableThirdPartyExtensions] else { + return errorResponse(code: RPCErrorCode.featureDisabled, message: "Extensions are disabled", id: id) + } + guard Defaults[.enableExtensionFileSharing] else { + return errorResponse(code: RPCErrorCode.featureDisabled, message: "Extension file sharing is disabled", id: id) + } + let entry = authorizationManager.authorizationEntry(for: bundleIdentifier) + guard entry?.isAuthorized == true else { + return errorResponse(code: RPCErrorCode.unauthorized, message: "Extension not authorized", id: id) + } + guard entry?.allowedScopes.contains(.fileSharing) == true else { + return errorResponse(code: RPCErrorCode.unauthorized, message: "File sharing scope not granted", id: id) + } + return nil + } + + // MARK: - File Sharing Handlers + + private func handleGetShelfItems(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + let items = ShelfStateViewModel.shared.items + var result: [RPCValue] = [] + + for item in items { + var entry: [String: RPCValue] = [ + "id": .string(item.id.uuidString), + "name": .string(item.displayName) + ] + switch item.kind { + case .file(let bookmark): + entry["kind"] = .string("file") + if let url = Bookmark(data: bookmark).resolveURL() { + entry["path"] = .string(url.path) + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int { + entry["size"] = .int(size) + } + } + case .text(let string): + entry["kind"] = .string("text") + entry["textContent"] = .string(string) + case .link(let url): + entry["kind"] = .string("link") + entry["url"] = .string(url.absoluteString) + } + result.append(.object(entry)) + } + + logDiagnostics("RPC: getShelfItems returned \(result.count) items for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["items": .array(result)], id: id) + } + + private func handleGetShelfItemData(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + guard let itemID = params?["itemID"]?.stringValue, + let uuid = UUID(uuidString: itemID) else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing or invalid itemID", id: id) + } + + guard let item = ShelfStateViewModel.shared.items.first(where: { $0.id == uuid }) else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Item not found", id: id) + } + + switch item.kind { + case .file(let bookmark): + guard let url = Bookmark(data: bookmark).resolveURL() else { + return errorResponse(code: RPCErrorCode.internalError, message: "Cannot resolve file bookmark", id: id) + } + guard let fileData = url.accessSecurityScopedResource(accessor: { try? Data(contentsOf: $0) }) else { + return errorResponse(code: RPCErrorCode.internalError, message: "Cannot read file data", id: id) + } + let base64 = fileData.base64EncodedString() + logDiagnostics("RPC: getShelfItemData returned \(fileData.count) bytes for \(bundleIdentifier)") + return RPCSuccessResponse(result: [ + "data": .string(base64), + "fileName": .string(url.lastPathComponent), + "mimeType": .string(url.mimeType ?? "application/octet-stream") + ], id: id) + case .text(let string): + return RPCSuccessResponse(result: [ + "data": .string(Data(string.utf8).base64EncodedString()), + "fileName": .string("text.txt"), + "mimeType": .string("text/plain") + ], id: id) + case .link(let url): + return RPCSuccessResponse(result: [ + "data": .string(Data(url.absoluteString.utf8).base64EncodedString()), + "fileName": .string("link.url"), + "mimeType": .string("text/uri-list") + ], id: id) + } + } + + private func handleShowFilePicker(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = true + panel.canChooseFiles = true + panel.title = "Select files to share" + + let response = panel.runModal() + guard response == .OK, !panel.urls.isEmpty else { + return RPCSuccessResponse(result: ["itemIDs": .array([])], id: id) + } + + var newItemIDs: [RPCValue] = [] + var newItems: [ShelfItem] = [] + + for url in panel.urls { + if let bookmark = try? Bookmark(url: url) { + let item = ShelfItem(kind: .file(bookmark: bookmark.data)) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + } + + ShelfStateViewModel.shared.add(newItems) + logDiagnostics("RPC: showFilePicker added \(newItems.count) items for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["itemIDs": .array(newItemIDs)], id: id) + } + + private func handleShareShelfItems(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + guard let itemIDsValue = params?["itemIDs"], + case .array(let itemIDArray) = itemIDsValue else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing itemIDs array", id: id) + } + + let provider = params?["provider"]?.stringValue ?? "AirDrop" + let itemIDs = itemIDArray.compactMap { $0.stringValue }.compactMap { UUID(uuidString: $0) } + let items = ShelfStateViewModel.shared.items.filter { itemIDs.contains($0.id) } + + guard !items.isEmpty else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "No matching shelf items found", id: id) + } + + let urls = ShelfStateViewModel.shared.resolveFileURLs(for: items) + guard !urls.isEmpty else { + return errorResponse(code: RPCErrorCode.internalError, message: "Could not resolve file URLs", id: id) + } + + // Find and invoke the sharing service + QuickShareService.shared.ensureDiscovered() + if let shareProvider = QuickShareService.shared.availableProviders.first(where: { $0.id == provider }) { + Task { + await QuickShareService.shared.shareFilesOrText(urls, using: shareProvider, from: nil) + } + logDiagnostics("RPC: shareShelfItems sharing \(urls.count) files via \(provider) for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true), "fileCount": .int(urls.count)], id: id) + } else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Provider '\(provider)' not found", id: id) + } + } + + private func handleAddFilesToShelf(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + var newItems: [ShelfItem] = [] + var newItemIDs: [RPCValue] = [] + + // Handle file paths + if let pathsValue = params?["paths"], + case .array(let pathArray) = pathsValue { + for pathValue in pathArray { + guard let path = pathValue.stringValue else { continue } + let url = URL(fileURLWithPath: path) + guard FileManager.default.fileExists(atPath: path) else { continue } + if let bookmark = try? Bookmark(url: url) { + let item = ShelfItem(kind: .file(bookmark: bookmark.data)) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + } + } + + // Handle base64-encoded file data + if let filesValue = params?["files"], + case .array(let filesArray) = filesValue { + for fileValue in filesArray { + guard case .object(let fileObj) = fileValue, + let dataStr = fileObj["data"]?.stringValue, + let fileName = fileObj["fileName"]?.stringValue, + let fileData = Data(base64Encoded: dataStr) else { continue } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("AtollExtensionFiles", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent(fileName) + guard (try? fileData.write(to: fileURL)) != nil else { continue } + + if let bookmark = try? Bookmark(url: fileURL) { + let item = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + } + } + + // Handle text content + if let text = params?["text"]?.stringValue, !text.isEmpty { + let item = ShelfItem(kind: .text(string: text)) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + + ShelfStateViewModel.shared.add(newItems) + logDiagnostics("RPC: addFilesToShelf added \(newItems.count) items for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["itemIDs": .array(newItemIDs)], id: id) + } + + private func handleSubscribeShelfEvents(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + server?.registerShelfSubscription(for: bundleIdentifier) + logDiagnostics("RPC: subscribeShelfEvents registered for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["subscribed": .bool(true)], id: id) + } + // MARK: - Client JSON Transformation /// Converts client wire format to Swift Codable format for enum types. @@ -452,3 +699,12 @@ final class ExtensionRPCService { return [typeName: inner] } } + +// MARK: - URL MIME Type Helper + +private extension URL { + var mimeType: String? { + guard let uti = UTType(filenameExtension: self.pathExtension) else { return nil } + return uti.preferredMIMEType + } +} From 794e05c7d67ba67e7933de2b95e4028eb36d6f5f Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 28 Mar 2026 15:53:39 +0530 Subject: [PATCH 2/7] Update: Popover for Localsend --- DynamicIsland.xcodeproj/project.pbxproj | 18 +- DynamicIsland/Localizable.xcstrings | 36 ++ .../components/Settings/SettingsView.swift | 42 +- .../components/Shelf/AirDropView.swift | 17 +- .../Shelf/Views/FileShareView.swift | 95 ++-- .../LocalSendDevicePickerWindowManager.swift | 530 ++++++++++++++++++ DynamicIsland/models/Constants.swift | 2 + 7 files changed, 665 insertions(+), 75 deletions(-) create mode 100644 DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 4c209ab..021b45f 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -58,13 +58,6 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - 7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - Info.plist, - ); - target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */; - }; 7EA69B1E2F5AE61200EB2D16 /* Exceptions for "Contents" folder in "DynamicIsland" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -72,6 +65,13 @@ ); target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */; }; + 7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ @@ -454,7 +454,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; - DEVELOPMENT_TEAM = 5W5BDQQ32W; + DEVELOPMENT_TEAM = 9Y64TRM77N; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -526,7 +526,7 @@ CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 5W5BDQQ32W; + "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 64ead76..7fb3de0 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -10032,6 +10032,10 @@ } } }, + "Customize the appearance of the LocalSend device selection popup that appears when you drop files." : { + "comment" : "A description of the LocalSend device picker.", + "isCommentAutoGenerated" : true + }, "Dash outline equals the exact notch bounds." : { "comment" : "A description under the live preview section of the animation editor.", "isCommentAutoGenerated" : true, @@ -10493,6 +10497,10 @@ } } }, + "Device Picker Style" : { + "comment" : "A dropdown menu that lets you choose how the LocalSend device picker is displayed.", + "isCommentAutoGenerated" : true + }, "Devices" : { "comment" : "The title of the \"Devices\" section in the settings menu.", "isCommentAutoGenerated" : true, @@ -16737,6 +16745,10 @@ } } }, + "Liquid Glass Variant" : { + "comment" : "A label for selecting the variant of the Liquid Glass effect.", + "isCommentAutoGenerated" : true + }, "Listens for Focus session changes via distributed notifications" : { "localizations" : { "ko" : { @@ -16927,6 +16939,10 @@ } } }, + "LocalSend Device Picker" : { + "comment" : "A section header for the LocalSend device picker settings.", + "isCommentAutoGenerated" : true + }, "Lock Screen" : { "localizations" : { "fr" : { @@ -17522,6 +17538,10 @@ } } }, + "Make sure LocalSend is running on other devices" : { + "comment" : "A description of the error.", + "isCommentAutoGenerated" : true + }, "Manage indicator preferences from Settings → Privacy. No manual microphone toggle is required." : { "comment" : "A description of how to manage the microphone privacy indicator preferences.", "isCommentAutoGenerated" : true, @@ -24854,6 +24874,14 @@ } } }, + "Scan Again" : { + "comment" : "A button to refresh the list of nearby devices.", + "isCommentAutoGenerated" : true + }, + "Scanning for devices..." : { + "comment" : "A description of the scanning process for devices.", + "isCommentAutoGenerated" : true + }, "Screen Assistant" : { "localizations" : { "ko" : { @@ -25275,6 +25303,10 @@ } } }, + "Select Device" : { + "comment" : "A label displayed above the LocalSend device picker view.", + "isCommentAutoGenerated" : true + }, "Select one or more profiles to customize your experience" : { "comment" : "A description below the header that instructs users to select one or more profiles to customize their experience.", "isCommentAutoGenerated" : true, @@ -30867,6 +30899,10 @@ }, "shouldTranslate" : false }, + "Variant %lld" : { + "comment" : "A label for a specific variant of the Liquid Glass.", + "isCommentAutoGenerated" : true + }, "Version" : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 624d355..a4b40d7 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -793,7 +793,8 @@ struct SettingsView: View { SettingsSearchEntry(tab: .shelf, title: "Expanded drag detection area", keywords: ["shelf", "drag"], highlightID: SettingsTab.shelf.highlightID(for: "Expanded drag detection area")), SettingsSearchEntry(tab: .shelf, title: "Copy items on drag", keywords: ["shelf", "drag", "copy"], highlightID: SettingsTab.shelf.highlightID(for: "Copy items on drag")), SettingsSearchEntry(tab: .shelf, title: "Remove from shelf after dragging", keywords: ["shelf", "drag", "remove"], highlightID: SettingsTab.shelf.highlightID(for: "Remove from shelf after dragging")), - SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")), + SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop", "localsend"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")), + SettingsSearchEntry(tab: .shelf, title: "LocalSend Device Picker Style", keywords: ["localsend", "glass", "picker", "material"], highlightID: SettingsTab.shelf.highlightID(for: "Device Picker Style")), // Appearance SettingsSearchEntry(tab: .appearance, title: "Main screen style", keywords: ["dynamic island", "pill", "non-notch", "display style", "notch style"], highlightID: SettingsTab.appearance.highlightID(for: "Main screen style")), @@ -3486,6 +3487,10 @@ struct Shelf: View { .font(.caption) .foregroundStyle(.secondary) } + + if quickShareProvider == "LocalSend" { + LocalSendSettingsSection(highlightID: highlightID) + } } .accentColor(.effectiveAccent) .navigationTitle("Shelf") @@ -3496,6 +3501,41 @@ struct Shelf: View { } } +// MARK: - LocalSend Settings Section + +private struct LocalSendSettingsSection: View { + let highlightID: (String) -> String + + @Default(.localSendDevicePickerGlassMode) private var glassMode + @Default(.localSendDevicePickerLiquidGlassVariant) private var liquidGlassVariant + + var body: some View { + Section { + Picker("Device Picker Style", selection: $glassMode) { + ForEach(LockScreenGlassCustomizationMode.allCases) { mode in + Text(mode.localizedName).tag(mode) + } + } + .pickerStyle(.menu) + + if glassMode == .customLiquid { + Picker("Liquid Glass Variant", selection: $liquidGlassVariant) { + ForEach(LiquidGlassVariant.allCases) { variant in + Text("Variant \(variant.rawValue)").tag(variant) + } + } + .pickerStyle(.menu) + } + } header: { + Text("LocalSend Device Picker") + } footer: { + Text("Customize the appearance of the LocalSend device selection popup that appears when you drop files.") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} + struct LiveActivitiesSettings: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared diff --git a/DynamicIsland/components/Shelf/AirDropView.swift b/DynamicIsland/components/Shelf/AirDropView.swift index 1199646..efa83b0 100644 --- a/DynamicIsland/components/Shelf/AirDropView.swift +++ b/DynamicIsland/components/Shelf/AirDropView.swift @@ -29,7 +29,7 @@ struct AirDropView: View { @Default(.quickShareProvider) var quickShareProvider @StateObject private var quickShareService = QuickShareService.shared @State private var showShareSettings = false - @State private var isGearHover = false + @State private var isSwitchHover = false var body: some View { dropArea @@ -50,27 +50,26 @@ struct AirDropView: View { ZStack { dropLabel - // Gear in the bottom-right for quick-share settings + // Switch button in the top-right for quick-share settings VStack { - Spacer() HStack { Spacer() Button { quickShareService.ensureDiscovered() showShareSettings.toggle() } label: { - Image(systemName: "gearshape") + Image(systemName: "switch.2") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) .padding(8) .background( RoundedRectangle(cornerRadius: 8) - .fill(isGearHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear) + .fill(isSwitchHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear) ) - .foregroundColor(isGearHover ? .accentColor : .gray) + .foregroundColor(isSwitchHover ? .accentColor : .gray) .contentShape(Rectangle()) - .onHover { over in isGearHover = over } + .onHover { over in isSwitchHover = over } .help("Quick Share settings") } .buttonStyle(.plain) @@ -132,8 +131,10 @@ struct AirDropView: View { } .padding() } - .padding(8) + .padding(.trailing, 8) + .padding(.top, 8) } + Spacer() } } } diff --git a/DynamicIsland/components/Shelf/Views/FileShareView.swift b/DynamicIsland/components/Shelf/Views/FileShareView.swift index 5795f16..cfcc90b 100644 --- a/DynamicIsland/components/Shelf/Views/FileShareView.swift +++ b/DynamicIsland/components/Shelf/Views/FileShareView.swift @@ -31,12 +31,14 @@ struct FileShareView: View { @StateObject private var localSend = LocalSendService.shared @Default(.quickShareProvider) var quickShareProvider: String @State private var showQuickSharePopover = false - @State private var isGearHover = false + @State private var isSwitchHover = false @State private var autoCloseToken = UUID() @State private var hostView: NSView? @State private var interactionNonce: UUID = .init() @State private var isProcessing = false + @State private var pendingDropProviders: [NSItemProvider]? + @State private var showLocalSendPicker = false private var selectedProvider: QuickShareProvider { quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true) @@ -56,7 +58,12 @@ struct FileShareView: View { .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in interactionNonce = .init() vm.dropEvent = true - Task { await handleDrop(providers) } + if selectedProvider.id == "LocalSend" { + pendingDropProviders = providers + showLocalSendPicker = true + } else { + Task { await handleDrop(providers) } + } return true } .onTapGesture { @@ -125,76 +132,30 @@ struct FileShareView: View { .truncationMode(.tail) .frame(maxWidth: .infinity) - // Share mode selector moved to the notch tab gear icon - - if selectedProvider.id == "LocalSend" { - HStack(spacing: 8) { - Menu { - if localSend.devices.isEmpty { - Text("No devices found") - } else { - ForEach(localSend.devices) { device in - Button(device.displayName) { - localSend.selectedDeviceID = device.id - } - } - } - } label: { - HStack(spacing: 6) { - Image(systemName: "iphone.and.arrow.forward") - .font(.system(size: 11, weight: .semibold)) - Text(localSend.devices.first(where: { $0.id == localSend.selectedDeviceID })?.displayName ?? "Select device") - .font(.system(size: 11, weight: .medium)) - .lineLimit(1) - } - .foregroundStyle(.white.opacity(0.9)) - } - - Button { - localSend.refreshDeviceScan() - } label: { - Image(systemName: "arrow.clockwise") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.white.opacity(0.9)) - .rotationEffect(.degrees(localSend.isRefreshing ? 360 : 0)) - .animation( - localSend.isRefreshing - ? .linear(duration: 0.85).repeatForever(autoreverses: false) - : .easeOut(duration: 0.2), - value: localSend.isRefreshing - ) - } - .buttonStyle(.plain) - .disabled(localSend.isRefreshing) - .help("Refresh devices") - } - - } - } .padding(18) .frame(maxWidth: .infinity) - // Gear pinned to top-left corner of quick share box + // Switch button pinned to top-right corner VStack { HStack { + Spacer() Button { - // Ensure notch stays open immediately when opening the selector vm.setAutoCloseSuppression(true, token: autoCloseToken) quickShare.ensureDiscovered() showQuickSharePopover.toggle() } label: { - Image(systemName: "gear") + Image(systemName: "switch.2") .resizable() .aspectRatio(contentMode: .fit) .frame(width: 14, height: 14) .padding(8) - .background(RoundedRectangle(cornerRadius: 8).fill(isGearHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear)) - .foregroundColor(isGearHover ? .accentColor : .gray) + .background(RoundedRectangle(cornerRadius: 8).fill(isSwitchHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear)) + .foregroundColor(isSwitchHover ? .accentColor : .gray) } .buttonStyle(PlainButtonStyle()) .onHover { hovering in - isGearHover = hovering + isSwitchHover = hovering vm.setAutoCloseSuppression(hovering, token: autoCloseToken) } .popover(isPresented: $showQuickSharePopover, arrowEdge: .bottom) { @@ -267,10 +228,8 @@ struct FileShareView: View { } .onHover { hovering in vm.setAutoCloseSuppression(hovering, token: autoCloseToken) } } - .padding(.leading, 8) + .padding(.trailing, 8) .padding(.top, 8) - - Spacer() } Spacer() } @@ -319,6 +278,28 @@ struct FileShareView: View { } } .contentShape(RoundedRectangle(cornerRadius: 12)) + .onChange(of: showLocalSendPicker) { _, show in + if show { + LocalSendDevicePickerWindowManager.shared.show( + onDeviceSelected: { device in + localSend.selectedDeviceID = device.id + showLocalSendPicker = false + if let providers = pendingDropProviders { + Task { + await handleDrop(providers) + pendingDropProviders = nil + } + } + }, + onDismiss: { + showLocalSendPicker = false + pendingDropProviders = nil + } + ) + } else { + LocalSendDevicePickerWindowManager.shared.hide() + } + } } // MARK: - Actions diff --git a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift new file mode 100644 index 0000000..8300f8b --- /dev/null +++ b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift @@ -0,0 +1,530 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit +import SwiftUI +import Defaults + +@MainActor +final class LocalSendDevicePickerWindowManager { + static let shared = LocalSendDevicePickerWindowManager() + + private var window: NSWindow? + private var onDeviceSelected: ((LocalSendDeviceInfo) -> Void)? + private var onDismiss: (() -> Void)? + + private init() {} + + func show( + onDeviceSelected: @escaping (LocalSendDeviceInfo) -> Void, + onDismiss: @escaping () -> Void + ) { + self.onDeviceSelected = onDeviceSelected + self.onDismiss = onDismiss + + let pickerView = LocalSendDevicePickerView( + onDeviceSelected: { [weak self] device in + self?.onDeviceSelected?(device) + self?.hide() + }, + onDismiss: { [weak self] in + self?.onDismiss?() + self?.hide() + } + ) + + if let existingWindow = window { + existingWindow.contentView = NSHostingView(rootView: pickerView) + existingWindow.makeKeyAndOrderFront(nil) + return + } + + guard let screen = NSScreen.main else { return } + + let windowSize = CGSize(width: 480, height: 400) + let windowOrigin = CGPoint( + x: screen.frame.midX - windowSize.width / 2, + y: screen.frame.midY - windowSize.height / 2 + ) + + let newWindow = NSWindow( + contentRect: NSRect(origin: windowOrigin, size: windowSize), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.hasShadow = true + newWindow.level = .floating + newWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + newWindow.contentView = NSHostingView(rootView: pickerView) + newWindow.isMovableByWindowBackground = true + + window = newWindow + newWindow.makeKeyAndOrderFront(nil) + + // Add click-outside-to-dismiss + NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + guard let self, let window = self.window else { return event } + let locationInWindow = event.locationInWindow + let windowFrame = window.frame + let clickLocation = NSPoint( + x: windowFrame.origin.x + locationInWindow.x, + y: windowFrame.origin.y + locationInWindow.y + ) + + if !windowFrame.contains(clickLocation) && event.window != window { + self.onDismiss?() + self.hide() + } + return event + } + } + + func hide() { + window?.orderOut(nil) + window = nil + onDeviceSelected = nil + onDismiss = nil + } +} + +// MARK: - LocalSend Device Picker View + +struct LocalSendDevicePickerView: View { + @StateObject private var localSend = LocalSendService.shared + @Default(.localSendDevicePickerGlassMode) private var glassMode + @Default(.localSendDevicePickerLiquidGlassVariant) private var liquidGlassVariant + + let onDeviceSelected: (LocalSendDeviceInfo) -> Void + let onDismiss: () -> Void + + @State private var hoveredDeviceID: String? + + private let cornerRadius: CGFloat = 24 + + private var usesCustomLiquidGlass: Bool { + glassMode == .customLiquid + } + + private var usesStandardLiquidGlass: Bool { + guard glassMode == .standard else { return false } + if #available(macOS 26.0, *) { + return true + } + return false + } + + var body: some View { + ZStack { + // Background + panelBackground + + // Content + VStack(spacing: 16) { + // Header + HStack { + Image("LocalSend") + .resizable() + .scaledToFit() + .frame(width: 28, height: 28) + + Text("Select Device") + .font(.system(size: 18, weight: .semibold, design: .rounded)) + .foregroundColor(.white) + + Spacer() + + // Refresh button + Button { + localSend.refreshDeviceScan() + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white.opacity(0.8)) + .rotationEffect(.degrees(localSend.isRefreshing ? 360 : 0)) + .animation( + localSend.isRefreshing + ? .linear(duration: 0.85).repeatForever(autoreverses: false) + : .easeOut(duration: 0.2), + value: localSend.isRefreshing + ) + .frame(width: 32, height: 32) + .background(Circle().fill(Color.white.opacity(0.1))) + } + .buttonStyle(.plain) + .disabled(localSend.isRefreshing) + .help("Refresh devices") + + // Close button + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white.opacity(0.6)) + .frame(width: 28, height: 28) + .background(Circle().fill(Color.white.opacity(0.1))) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 20) + .padding(.top, 20) + + // Devices grid + if localSend.devices.isEmpty { + emptyStateView + } else { + devicesGridView + } + } + } + .frame(width: 480, height: 400) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + } + .shadow(color: Color.black.opacity(0.4), radius: 30, x: 0, y: 15) + .onAppear { + localSend.startDiscovery() + localSend.refreshDeviceScan() + } + } + + @ViewBuilder + private var panelBackground: some View { + if usesCustomLiquidGlass { + LiquidGlassBackground(variant: liquidGlassVariant, cornerRadius: cornerRadius) { + Color.black.opacity(0.3) + } + } else if usesStandardLiquidGlass { + if #available(macOS 26.0, *) { + LocalSendGlassBackdrop(cornerRadius: cornerRadius) + } + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.ultraThinMaterial) + .background( + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color.black.opacity(0.4)) + ) + } + } + + private var emptyStateView: some View { + VStack(spacing: 12) { + Spacer() + + if localSend.isRefreshing { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.2) + + Text("Scanning for devices...") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } else { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.system(size: 48, weight: .light)) + .foregroundColor(.white.opacity(0.4)) + + Text("No devices found") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + + Text("Make sure LocalSend is running on other devices") + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.5)) + .multilineTextAlignment(.center) + + Button { + localSend.refreshDeviceScan() + } label: { + Text("Scan Again") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color.accentColor.opacity(0.8)) + ) + } + .buttonStyle(.plain) + .padding(.top, 8) + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var devicesGridView: some View { + ScrollView { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ], + spacing: 16 + ) { + ForEach(localSend.devices) { device in + DeviceGridItem( + device: device, + isHovered: hoveredDeviceID == device.id, + onSelect: { + onDeviceSelected(device) + } + ) + .onHover { hovering in + hoveredDeviceID = hovering ? device.id : nil + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } +} + +// MARK: - Device Grid Item + +private struct DeviceGridItem: View { + let device: LocalSendDeviceInfo + let isHovered: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack(spacing: 10) { + // Device icon + ZStack { + Circle() + .fill(Color.white.opacity(isHovered ? 0.15 : 0.08)) + .frame(width: 56, height: 56) + + Image(systemName: deviceIcon) + .font(.system(size: 24, weight: .medium)) + .foregroundColor(isHovered ? .accentColor : .white.opacity(0.8)) + } + + // Device name + VStack(spacing: 2) { + Text(device.alias) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + if let model = device.model, !model.isEmpty { + Text(model) + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.5)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + RoundedRectangle(cornerRadius: 16) + .fill(Color.white.opacity(isHovered ? 0.12 : 0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(isHovered ? Color.accentColor.opacity(0.6) : Color.white.opacity(0.1), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .scaleEffect(isHovered ? 1.02 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered) + } + + private var deviceIcon: String { + let aliasLower = device.alias.lowercased() + let modelLower = (device.model ?? "").lowercased() + let combined = aliasLower + " " + modelLower + + // iPhone / iOS devices + if combined.contains("iphone") { + return "iphone" + } + if combined.contains("ipad") { + return "ipad" + } + if combined.contains("ipod") { + return "ipodtouch" + } + + // Android phones by brand + if combined.contains("pixel") || combined.contains("nexus") { + return "smartphone" + } + if combined.contains("samsung") || combined.contains("galaxy") { + return "smartphone" + } + if combined.contains("oneplus") || combined.contains("one plus") { + return "smartphone" + } + if combined.contains("xiaomi") || combined.contains("redmi") || combined.contains("poco") || combined.contains("mi ") { + return "smartphone" + } + if combined.contains("huawei") || combined.contains("honor") { + return "smartphone" + } + if combined.contains("oppo") || combined.contains("realme") || combined.contains("vivo") { + return "smartphone" + } + if combined.contains("motorola") || combined.contains("moto ") { + return "smartphone" + } + if combined.contains("nokia") { + return "smartphone" + } + if combined.contains("sony") || combined.contains("xperia") { + return "smartphone" + } + if combined.contains("lg") { + return "smartphone" + } + if combined.contains("asus") && (combined.contains("phone") || combined.contains("rog")) { + return "smartphone" + } + if combined.contains("nothing") && combined.contains("phone") { + return "smartphone" + } + + // Android tablets + if combined.contains("tab") || combined.contains("tablet") { + return "tablet.portrait" + } + + // Mac devices + if combined.contains("macbook") { + return "laptopcomputer" + } + if combined.contains("imac") { + return "desktopcomputer" + } + if combined.contains("mac mini") || combined.contains("mini") && combined.contains("mac") { + return "macmini" + } + if combined.contains("mac pro") { + return "macpro.gen3" + } + if combined.contains("mac studio") { + return "macstudio" + } + if combined.contains("mac") || combined.contains("macos") { + return "desktopcomputer" + } + + // Windows/Linux laptops and desktops + if combined.contains("laptop") || combined.contains("notebook") { + return "laptopcomputer" + } + if combined.contains("thinkpad") || combined.contains("lenovo") { + return "laptopcomputer" + } + if combined.contains("dell") || combined.contains("xps") || combined.contains("inspiron") || combined.contains("latitude") { + return "laptopcomputer" + } + if combined.contains("hp") || combined.contains("pavilion") || combined.contains("envy") || combined.contains("spectre") || combined.contains("elitebook") { + return "laptopcomputer" + } + if combined.contains("asus") || combined.contains("zenbook") || combined.contains("vivobook") { + return "laptopcomputer" + } + if combined.contains("acer") || combined.contains("aspire") || combined.contains("swift") { + return "laptopcomputer" + } + if combined.contains("msi") { + return "laptopcomputer" + } + if combined.contains("surface") { + return "laptopcomputer" + } + if combined.contains("razer") { + return "laptopcomputer" + } + if combined.contains("chromebook") { + return "laptopcomputer" + } + + // Desktop keywords + if combined.contains("desktop") || combined.contains("pc") || combined.contains("workstation") { + return "desktopcomputer" + } + if combined.contains("windows") || combined.contains("linux") || combined.contains("ubuntu") || combined.contains("fedora") { + return "desktopcomputer" + } + + // Android generic + if combined.contains("android") { + return "smartphone" + } + + // Phone/Mobile generic + if combined.contains("phone") || combined.contains("mobile") { + return "smartphone" + } + + // TV devices + if combined.contains("tv") || combined.contains("shield") || combined.contains("fire") || combined.contains("roku") || combined.contains("chromecast") { + return "appletv" + } + + // Default: try to guess based on common patterns + // If it looks like a computer name with a person's name, assume laptop + if combined.contains("'s ") || combined.contains("-pc") || combined.contains("-laptop") { + return "laptopcomputer" + } + + // Default fallback + return "desktopcomputer" + } +} + +// MARK: - Glass Backdrop for LocalSend Picker + +@available(macOS 26.0, *) +private struct LocalSendGlassBackdrop: View { + let cornerRadius: CGFloat + + var body: some View { + GeometryReader { proxy in + let dynamicFontSize = max(min(proxy.size.width, proxy.size.height) / 8, 42) + + Text("LocalSend Device Picker") + .font(.system(size: dynamicFontSize, weight: .semibold, design: .rounded)) + .foregroundStyle(Color.clear) + .frame(width: proxy.size.width, height: proxy.size.height) + .glassEffect( + .clear.interactive(), + in: .rect(cornerRadius: cornerRadius) + ) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 392c081..c03afc5 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -889,6 +889,8 @@ extension Defaults.Keys { static let openShelfByDefault = Key("openShelfByDefault", default: true) static let quickShareProvider = Key("quickShareProvider", default: "AirDrop") static let localSendSelectedDeviceID = Key("localSendSelectedDeviceID", default: "") + static let localSendDevicePickerGlassMode = Key("localSendDevicePickerGlassMode", default: .standard) + static let localSendDevicePickerLiquidGlassVariant = Key("localSendDevicePickerLiquidGlassVariant", default: .v11) static let copyOnDrag = Key("copyOnDrag", default: false) static let autoRemoveShelfItems = Key("autoRemoveShelfItems", default: false) static let expandedDragDetection = Key("expandedDragDetection", default: true) From bbde9fa73fbd206776b2ee7d25eabb0fc8830e11 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sat, 28 Mar 2026 16:01:45 +0530 Subject: [PATCH 3/7] Update default icon --- DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift index 8300f8b..8fa47d9 100644 --- a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift +++ b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift @@ -501,7 +501,7 @@ private struct DeviceGridItem: View { } // Default fallback - return "desktopcomputer" + return "smartphone" } } From 3fffb22cf78b3c69f952ef06bb393bcaaa827369 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 29 Mar 2026 14:37:00 +0530 Subject: [PATCH 4/7] Update stuff --- DynamicIsland/components/Settings/SettingsView.swift | 12 ++++++++---- DynamicIsland/components/Shelf/AirDropView.swift | 9 +++++++-- .../components/Shelf/Views/FileShareView.swift | 9 +++++++-- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index a4b40d7..cfdfc26 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -3439,12 +3439,14 @@ struct Shelf: View { if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) } else { Image(systemName: "square.and.arrow.up") + .frame(width: 16, height: 16) } } - .frame(width: 16, height: 16) .foregroundColor(.accentColor) Text(provider.id) } @@ -3460,12 +3462,14 @@ struct Shelf: View { if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) } else { Image(systemName: "square.and.arrow.up") + .frame(width: 16, height: 16) } } - .frame(width: 16, height: 16) .foregroundColor(.accentColor) VStack(alignment: .leading, spacing: 2) { Text("Currently selected: \(selectedProvider.id)") diff --git a/DynamicIsland/components/Shelf/AirDropView.swift b/DynamicIsland/components/Shelf/AirDropView.swift index efa83b0..a65bc3a 100644 --- a/DynamicIsland/components/Shelf/AirDropView.swift +++ b/DynamicIsland/components/Shelf/AirDropView.swift @@ -85,12 +85,14 @@ struct AirDropView: View { if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) } else { Image(systemName: "square.and.arrow.up") + .frame(width: 16, height: 16) } } - .frame(width: 16, height: 16) .foregroundColor(.accentColor) Text(provider.id) @@ -109,9 +111,12 @@ struct AirDropView: View { if let imgData = selected.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() + .scaledToFit() .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) } else { Image(systemName: "square.and.arrow.up") + .frame(width: 20, height: 20) } } .foregroundColor(.accentColor) diff --git a/DynamicIsland/components/Shelf/Views/FileShareView.swift b/DynamicIsland/components/Shelf/Views/FileShareView.swift index cfcc90b..de7a761 100644 --- a/DynamicIsland/components/Shelf/Views/FileShareView.swift +++ b/DynamicIsland/components/Shelf/Views/FileShareView.swift @@ -170,12 +170,14 @@ struct FileShareView: View { if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) } else { Image(systemName: "square.and.arrow.up") + .frame(width: 16, height: 16) } } - .frame(width: 16, height: 16) .foregroundColor(.accentColor) Text(provider.id) @@ -196,9 +198,12 @@ struct FileShareView: View { if let imgData = selected.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() + .scaledToFit() .frame(width: 20, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 4)) } else { Image(systemName: "square.and.arrow.up") + .frame(width: 20, height: 20) } } .foregroundColor(.accentColor) From 79f41cb69e85d3542ed5f3681c225f2f62e67e57 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 29 Mar 2026 15:58:38 +0530 Subject: [PATCH 5/7] Update phases --- DynamicIsland/ContentView.swift | 17 ++ DynamicIsland/Localizable.xcstrings | 4 + .../Shelf/Services/LocalSendService.swift | 67 ++++++- .../Shelf/Views/FileShareView.swift | 3 +- .../Shelf/Views/LocalSendLiveActivity.swift | 173 ++++++++++++++++++ .../LocalSendDevicePickerWindowManager.swift | 38 +++- 6 files changed, 289 insertions(+), 13 deletions(-) create mode 100644 DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 24b6ce7..c8059cd 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -52,6 +52,7 @@ struct ContentView: View { @ObservedObject var capsLockManager = CapsLockManager.shared @ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared @ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared + @ObservedObject var localSendService = LocalSendService.shared @State private var downloadManager = DownloadManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -293,6 +294,19 @@ struct ContentView: View { private var shouldUseHiddenEdgeHoverPolling: Bool { shouldHideUntilHover && !lockScreenManager.isLocked } + + /// Whether the LocalSend live activity should be shown + private var localSendLiveActivityActive: Bool { + localSendService.isSending || + localSendService.transferState == .completed || + isLocalSendFailedOrRejected + } + + private var isLocalSendFailedOrRejected: Bool { + if case .failed = localSendService.transferState { return true } + if case .rejected = localSendService.transferState { return true } + return false + } /// Pill shape for Dynamic Island mode with animated corner radius transitions. private var currentPillShape: DynamicIslandPillShape { @@ -738,6 +752,9 @@ struct ContentView: View { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { DownloadLiveActivity() .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) + } else if !coordinator.expandingView.show && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed { + LocalSendLiveActivity() + .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked { DoNotDisturbLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 7fb3de0..e8f1a17 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -23715,6 +23715,10 @@ }, "Refresh devices" : { + }, + "Rejected" : { + "comment" : "A label displayed when a device is rejected.", + "isCommentAutoGenerated" : true }, "Release name" : { "localizations" : { diff --git a/DynamicIsland/components/Shelf/Services/LocalSendService.swift b/DynamicIsland/components/Shelf/Services/LocalSendService.swift index 62618c4..e1d5d42 100644 --- a/DynamicIsland/components/Shelf/Services/LocalSendService.swift +++ b/DynamicIsland/components/Shelf/Services/LocalSendService.swift @@ -35,6 +35,14 @@ struct LocalSendDeviceInfo: Identifiable, Hashable, Sendable { } } +enum LocalSendTransferState: Equatable { + case idle + case sending + case completed + case failed(String) + case rejected(deviceID: String) +} + @MainActor final class LocalSendService: NSObject, ObservableObject { static let shared = LocalSendService() @@ -43,6 +51,8 @@ final class LocalSendService: NSObject, ObservableObject { @Published private(set) var isRefreshing = false @Published private(set) var isSending = false @Published private(set) var sendProgress: Double = 0 + @Published private(set) var transferState: LocalSendTransferState = .idle + @Published private(set) var rejectedDeviceIDs: Set = [] @Published var selectedDeviceID: String { didSet { Defaults[.localSendSelectedDeviceID] = selectedDeviceID } } @@ -59,11 +69,20 @@ final class LocalSendService: NSObject, ObservableObject { private var recentProbeIPs: [String] = [] private var knownPeerIPs: [String] = [] private var isStarted = false + private var completionDismissTask: Task? private override init() { selectedDeviceID = Defaults[.localSendSelectedDeviceID] super.init() } + + func clearRejectedStatus(for deviceID: String) { + rejectedDeviceIDs.remove(deviceID) + } + + func clearAllRejectedStatuses() { + rejectedDeviceIDs.removeAll() + } func startDiscovery() { guard !isStarted else { return } @@ -483,6 +502,8 @@ final class LocalSendService: NSObject, ObservableObject { let startedAt = Date() isSending = true sendProgress = 0 + transferState = .sending + completionDismissTask?.cancel() do { guard let target = devices.first(where: { $0.id == selectedDeviceID }) ?? devices.first else { @@ -495,7 +516,7 @@ final class LocalSendService: NSObject, ObservableObject { let prepare = try await prepareUpload(files: files, to: target) guard !prepare.fileTokens.isEmpty else { sendProgress = 1 - await finishSending(startedAt: startedAt) + await finishSending(startedAt: startedAt, success: true) return } @@ -527,14 +548,24 @@ final class LocalSendService: NSObject, ObservableObject { } sendProgress = 1 - await finishSending(startedAt: startedAt) + await finishSending(startedAt: startedAt, success: true) + } catch let error as LocalSendServiceError { + if case .transferRejected = error { + rejectedDeviceIDs.insert(selectedDeviceID) + transferState = .rejected(deviceID: selectedDeviceID) + } else { + transferState = .failed(error.localizedDescription) + } + await finishSending(startedAt: startedAt, success: false) + throw error } catch { - await finishSending(startedAt: startedAt) + transferState = .failed(error.localizedDescription) + await finishSending(startedAt: startedAt, success: false) throw error } } - private func finishSending(startedAt: Date) async { + private func finishSending(startedAt: Date, success: Bool) async { let elapsed = Date().timeIntervalSince(startedAt) let minimumVisibleDuration: TimeInterval = 0.8 if elapsed < minimumVisibleDuration { @@ -543,6 +574,27 @@ final class LocalSendService: NSObject, ObservableObject { } isSending = false sendProgress = 0 + + if success { + transferState = .completed + // Auto-dismiss completed state after 3 seconds + completionDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 3_000_000_000) + if case .completed = self.transferState { + self.transferState = .idle + } + } + } else { + // Auto-dismiss failed/rejected state after 4 seconds + completionDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 4_000_000_000) + if case .failed = self.transferState { + self.transferState = .idle + } else if case .rejected = self.transferState { + self.transferState = .idle + } + } + } } private func sendAnnouncement() { @@ -880,6 +932,10 @@ final class LocalSendService: NSObject, ObservableObject { if http.statusCode == 204 { return ("", [:]) } + // HTTP 403 means the transfer was rejected by the recipient + if http.statusCode == 403 { + throw LocalSendServiceError.transferRejected + } guard (200 ... 299).contains(http.statusCode) else { let body = String(data: data, encoding: .utf8) throw LocalSendServiceError.server(status: http.statusCode, body: body) @@ -1037,6 +1093,7 @@ enum LocalSendServiceError: LocalizedError { case invalidTarget case invalidResponse case server(status: Int, body: String?) + case transferRejected var errorDescription: String? { switch self { @@ -1053,6 +1110,8 @@ enum LocalSendServiceError: LocalizedError { return "LocalSend peer error (\(status)): \(body)" } return "LocalSend peer error (\(status))" + case .transferRejected: + return "Transfer was rejected by the recipient" } } } \ No newline at end of file diff --git a/DynamicIsland/components/Shelf/Views/FileShareView.swift b/DynamicIsland/components/Shelf/Views/FileShareView.swift index de7a761..13e2316 100644 --- a/DynamicIsland/components/Shelf/Views/FileShareView.swift +++ b/DynamicIsland/components/Shelf/Views/FileShareView.swift @@ -288,8 +288,9 @@ struct FileShareView: View { LocalSendDevicePickerWindowManager.shared.show( onDeviceSelected: { device in localSend.selectedDeviceID = device.id - showLocalSendPicker = false if let providers = pendingDropProviders { + // Close the picker first since handleDrop will use quickShare properly + showLocalSendPicker = false Task { await handleDrop(providers) pendingDropProviders = nil diff --git a/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift b/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift new file mode 100644 index 0000000..15a72a2 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift @@ -0,0 +1,173 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct LocalSendLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @StateObject private var localSend = LocalSendService.shared + + @State private var isHovering: Bool = false + @State private var isExpanded: Bool = false + + private var tint: Color { + switch localSend.transferState { + case .completed: + return .green + case .failed, .rejected: + return .red + default: + return .accentColor + } + } + + private var isActive: Bool { + localSend.isSending || localSend.transferState == .completed || + isFailedOrRejected + } + + private var isFailedOrRejected: Bool { + if case .failed = localSend.transferState { return true } + if case .rejected = localSend.transferState { return true } + return false + } + + var body: some View { + HStack(spacing: 0) { + // Left side: upload icon capsule + Color.clear + .background { + if isExpanded { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint.opacity(0.14)) + + Image(systemName: leftIcon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + } + .frame( + width: vm.effectiveClosedNotchHeight - 12, + height: vm.effectiveClosedNotchHeight - 12 + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + .frame( + width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + + // Center: closed notch body (slightly wider during transfers) + Rectangle() + .fill(.black) + .frame( + width: vm.closedNotchSize.width + + (isHovering ? 8 : 0) + + (isActive ? 40 : 0) + ) + + // Right side: progress ring or status icon + Color.clear + .background { + if isExpanded { + HStack { + rightContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + .frame( + width: isExpanded ? max(60, vm.effectiveClosedNotchHeight) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + withAnimation(.smooth(duration: 0.35)) { + isExpanded = true + } + } + .onChange(of: isActive) { _, newValue in + withAnimation(.smooth(duration: 0.35)) { + isExpanded = newValue + } + } + } + + private var leftIcon: String { + // Always show document upload icon on left side + return "document.badge.arrow.up.fill" + } + + @ViewBuilder + private var rightContent: some View { + switch localSend.transferState { + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .failed: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .rejected: + Image(systemName: "hand.raised.fill") + .foregroundStyle(.orange) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .sending, .idle: + LocalSendProgressRing(progress: localSend.sendProgress) + .padding(.trailing, 6) + } + } +} + +// MARK: - Progress Ring + +private struct LocalSendProgressRing: View { + let progress: Double + + var body: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 2.5) + + Circle() + .trim(from: 0, to: min(max(progress, 0), 1)) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 2.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress) + + if progress > 0.99 { + Image(systemName: "checkmark") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(.accentColor) + } + } + .frame(width: 20, height: 20) + } +} diff --git a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift index 8fa47d9..b5bc850 100644 --- a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift +++ b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift @@ -49,7 +49,9 @@ final class LocalSendDevicePickerWindowManager { ) if let existingWindow = window { - existingWindow.contentView = NSHostingView(rootView: pickerView) + let hostingView = NSHostingView(rootView: pickerView) + hostingView.layer?.backgroundColor = .clear + existingWindow.contentView = hostingView existingWindow.makeKeyAndOrderFront(nil) return } @@ -74,7 +76,15 @@ final class LocalSendDevicePickerWindowManager { newWindow.hasShadow = true newWindow.level = .floating newWindow.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - newWindow.contentView = NSHostingView(rootView: pickerView) + + let hostingView = NSHostingView(rootView: + pickerView + .background(Color.clear) + ) + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = .clear + hostingView.layer?.isOpaque = false + newWindow.contentView = hostingView newWindow.isMovableByWindowBackground = true window = newWindow @@ -134,8 +144,12 @@ struct LocalSendDevicePickerView: View { var body: some View { ZStack { - // Background + // Explicitly clear background for proper transparency + Color.clear + + // Background - clips to rounded rect panelBackground + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) // Content VStack(spacing: 16) { @@ -203,6 +217,7 @@ struct LocalSendDevicePickerView: View { .stroke(Color.white.opacity(0.2), lineWidth: 1) } .shadow(color: Color.black.opacity(0.4), radius: 30, x: 0, y: 15) + .compositingGroup() .onAppear { localSend.startDiscovery() localSend.refreshDeviceScan() @@ -291,7 +306,9 @@ struct LocalSendDevicePickerView: View { DeviceGridItem( device: device, isHovered: hoveredDeviceID == device.id, + isRejected: localSend.rejectedDeviceIDs.contains(device.id), onSelect: { + localSend.clearRejectedStatus(for: device.id) onDeviceSelected(device) } ) @@ -311,6 +328,7 @@ struct LocalSendDevicePickerView: View { private struct DeviceGridItem: View { let device: LocalSendDeviceInfo let isHovered: Bool + let isRejected: Bool let onSelect: () -> Void var body: some View { @@ -319,23 +337,27 @@ private struct DeviceGridItem: View { // Device icon ZStack { Circle() - .fill(Color.white.opacity(isHovered ? 0.15 : 0.08)) + .fill(isRejected ? Color.red.opacity(0.15) : Color.white.opacity(isHovered ? 0.15 : 0.08)) .frame(width: 56, height: 56) Image(systemName: deviceIcon) .font(.system(size: 24, weight: .medium)) - .foregroundColor(isHovered ? .accentColor : .white.opacity(0.8)) + .foregroundColor(isRejected ? .red : (isHovered ? .accentColor : .white.opacity(0.8))) } // Device name VStack(spacing: 2) { Text(device.alias) .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) + .foregroundColor(isRejected ? .red : .white) .lineLimit(1) .truncationMode(.tail) - if let model = device.model, !model.isEmpty { + if isRejected { + Text("Rejected") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.red.opacity(0.8)) + } else if let model = device.model, !model.isEmpty { Text(model) .font(.system(size: 10)) .foregroundColor(.white.opacity(0.5)) @@ -348,7 +370,7 @@ private struct DeviceGridItem: View { .padding(.vertical, 16) .background( RoundedRectangle(cornerRadius: 16) - .fill(Color.white.opacity(isHovered ? 0.12 : 0.06)) + .fill(isRejected ? Color.red.opacity(0.08) : Color.white.opacity(isHovered ? 0.12 : 0.06)) ) .overlay( RoundedRectangle(cornerRadius: 16) From aee19c90d687c83f84fa3e1ca9a5033256201246 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Sun, 29 Mar 2026 16:06:23 +0530 Subject: [PATCH 6/7] BugFix: remove sharp corners --- .../LocalSendDevicePickerWindowManager.swift | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift index b5bc850..387bdf1 100644 --- a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift +++ b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift @@ -20,6 +20,16 @@ import AppKit import SwiftUI import Defaults +private func applyLocalSendPanelCornerMask(_ view: NSView, radius: CGFloat) { + view.wantsLayer = true + view.layer?.masksToBounds = true + view.layer?.cornerRadius = radius + view.layer?.backgroundColor = NSColor.clear.cgColor + if #available(macOS 13.0, *) { + view.layer?.cornerCurve = .continuous + } +} + @MainActor final class LocalSendDevicePickerWindowManager { static let shared = LocalSendDevicePickerWindowManager() @@ -34,6 +44,7 @@ final class LocalSendDevicePickerWindowManager { onDeviceSelected: @escaping (LocalSendDeviceInfo) -> Void, onDismiss: @escaping () -> Void ) { + let cornerRadius: CGFloat = 24 self.onDeviceSelected = onDeviceSelected self.onDismiss = onDismiss @@ -50,7 +61,7 @@ final class LocalSendDevicePickerWindowManager { if let existingWindow = window { let hostingView = NSHostingView(rootView: pickerView) - hostingView.layer?.backgroundColor = .clear + applyLocalSendPanelCornerMask(hostingView, radius: cornerRadius) existingWindow.contentView = hostingView existingWindow.makeKeyAndOrderFront(nil) return @@ -81,9 +92,7 @@ final class LocalSendDevicePickerWindowManager { pickerView .background(Color.clear) ) - hostingView.wantsLayer = true - hostingView.layer?.backgroundColor = .clear - hostingView.layer?.isOpaque = false + applyLocalSendPanelCornerMask(hostingView, radius: cornerRadius) newWindow.contentView = hostingView newWindow.isMovableByWindowBackground = true From 738a40ee885f707300be4d7a4fa186f8c7c83af6 Mon Sep 17 00:00:00 2001 From: StellarSea Date: Mon, 30 Mar 2026 10:17:50 +0900 Subject: [PATCH 7/7] fix: correct localized string typo and replace remaining hardcoded strings --- DynamicIsland/Localizable.xcstrings | 118 +++++++++++++++++- .../Providers/CalendarServiceProviding.swift | 2 +- .../components/Settings/SettingsView.swift | 111 ++++++++-------- .../Stats/CPUStatsSharedViews.swift | 10 +- .../components/Stats/GPUStatsDetailView.swift | 2 +- DynamicIsland/models/MusicControlButton.swift | 22 ++-- 6 files changed, 193 insertions(+), 72 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index a6ab283..ab8da99 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -3335,6 +3335,9 @@ } } } + }, + "Adjusts the lock screen timer widget width without affecting button sizing." : { + }, "AI Assistant" : { "localizations" : { @@ -6699,6 +6702,9 @@ } } } + }, + "Change Media Output" : { + }, "Charging" : { "comment" : "Charging fallback label when no estimate is available", @@ -9314,6 +9320,9 @@ } } } + }, + "Custom Focus metadata" : { + }, "Custom height" : { "localizations" : { @@ -10287,6 +10296,9 @@ } } } + }, + "Denied" : { + }, "Denied/Revoked" : { "comment" : "A label describing an extension with denied or revoked permissions.", @@ -11860,6 +11872,9 @@ } } } + }, + "Empty Slot" : { + }, "Enable \"Idle Animation\" to customize animations" : { "localizations" : { @@ -11994,6 +12009,9 @@ }, "Enable Color Picker" : { + }, + "Enable colored spectograms" : { + }, "Enable colored spectrograms" : { "localizations" : { @@ -13868,6 +13886,9 @@ } } } + }, + "Forward 10s" : { + }, "Free" : { @@ -13916,6 +13937,9 @@ } } } + }, + "Full Access" : { + }, "Full charge" : { "comment" : "Text displayed in the status text when the battery is fully charged.", @@ -13951,6 +13975,9 @@ } } } + }, + "Full Disk Access unlocks custom Focus icons, colors, and labels. Standard Focus detection still works without it—grant access only if you need personalized indicators." : { + }, "Fully charged" : { "comment" : "Charging fallback label when already charged", @@ -14922,6 +14949,9 @@ } } } + }, + "Hide active event and show next upcoming event" : { + }, "Hide all-day events" : { "comment" : "A toggle to hide all-day events in the calendar.", @@ -15365,6 +15395,9 @@ } }, "shouldTranslate" : false + }, + "Hours" : { + }, "https://example.com/animation.json" : { "comment" : "A placeholder text for a Lottie JSON URL input field.", @@ -16453,6 +16486,9 @@ } } } + }, + "Last Update" : { + }, "Last Updated" : { "localizations" : { @@ -17440,6 +17476,9 @@ } } } + }, + "Lyrics" : { + }, "Mac battery at %d percent" : { "comment" : "Mac battery gauge accessibility label", @@ -18370,6 +18409,9 @@ } } } + }, + "Media Panel Width" : { + }, "Media playback live activity" : { "localizations" : { @@ -19704,6 +19746,9 @@ } } } + }, + "Music" : { + }, "Music panel variant" : { "localizations" : { @@ -20334,6 +20379,9 @@ } } } + }, + "Next Track" : { + }, "No active interfaces detected yet." : { "comment" : "A message displayed when there are no active network interfaces to display.", @@ -21237,6 +21285,9 @@ } } } + }, + "Not Determined" : { + }, "Not Now" : { "localizations" : { @@ -22104,6 +22155,9 @@ }, "Open notch on hover" : { + }, + "Open Privacy & Security" : { + }, "Open shelf tab by default if items added" : { @@ -22585,6 +22639,9 @@ } } } + }, + "Play / Pause" : { + }, "Play feedback when volume is changed" : { @@ -22965,6 +23022,9 @@ } } } + }, + "Previous Track" : { + }, "Privacy Indicators" : { "comment" : "A heading for the section that lists privacy indicators.", @@ -23592,6 +23652,9 @@ } } } + }, + "Recommended minimum width adjusts automatically based on the number of enabled tabs." : { + }, "Recording" : { "localizations" : { @@ -24012,6 +24075,9 @@ }, "Renderer" : { + }, + "Repeat" : { + }, "Request Access" : { "localizations" : { @@ -24040,6 +24106,9 @@ } } } + }, + "Request Full Disk Access" : { + }, "Requires a third-party client with API plugin enabled." : { "comment" : "Description of a music controller type when it requires a third-party client with an API plugin enabled.", @@ -24166,6 +24235,12 @@ } } } + }, + "Reset Media Width" : { + + }, + "Reset Music" : { + }, "Reset Notch Experiences" : { "comment" : "A menu item that resets all notch experiences for a particular app.", @@ -24202,6 +24277,12 @@ } } } + }, + "Reset Timer" : { + + }, + "Reset Timer Width" : { + }, "Reset to Default" : { "localizations" : { @@ -24242,6 +24323,9 @@ } } } + }, + "Reset Weather" : { + }, "Reset Width" : { "comment" : "A button to reset the expanded notch width to its default value.", @@ -24479,6 +24563,9 @@ } } } + }, + "Restricted" : { + }, "Resume" : { "localizations" : { @@ -24525,6 +24612,9 @@ } } } + }, + "Rewind 10s" : { + }, "Rich Text" : { "comment" : "Name for a clipboard item type that represents a rich text format.", @@ -25158,7 +25248,6 @@ } }, "Seconds" : { - "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -26045,6 +26134,9 @@ } } } + }, + "Show \"Change Media Output\" control" : { + }, "Show All Color Formats" : { @@ -26451,6 +26543,9 @@ } } } + }, + "Show floating media controls" : { + }, "Show floating pause/stop controls" : { "localizations" : { @@ -26527,6 +26622,9 @@ }, "Show media controls in Dynamic Island" : { + }, + "Show merged AirPlay and output devices" : { + }, "Show next calendar event" : { @@ -27025,6 +27123,12 @@ } } } + }, + "Shrinks the lock screen media panel while keeping the expanded view full width." : { + + }, + "Shuffle" : { + }, "Simple and minimal interface with just the essentials for everyday tasks." : { "localizations" : { @@ -29423,6 +29527,9 @@ } } } + }, + "Timer Widget Width" : { + }, "Title" : { "comment" : "A label for the title input field in the note editor.", @@ -31043,6 +31150,9 @@ } } } + }, + "Vertical Bar" : { + }, "Vertical offset" : { "localizations" : { @@ -31719,6 +31829,9 @@ } } } + }, + "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value." : { + }, "Width:" : { "extractionState" : "stale", @@ -31819,6 +31932,9 @@ } } } + }, + "Write Only" : { + }, "Written" : { diff --git a/DynamicIsland/Providers/CalendarServiceProviding.swift b/DynamicIsland/Providers/CalendarServiceProviding.swift index 055de25..280b90d 100644 --- a/DynamicIsland/Providers/CalendarServiceProviding.swift +++ b/DynamicIsland/Providers/CalendarServiceProviding.swift @@ -272,7 +272,7 @@ extension EKCalendar { var accountTitle: String { switch source.sourceType { case .local, .subscribed, .birthdays: - return "Other" + return String(localized: "Other") default: return source.title } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 624d355..1022499 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1609,7 +1609,7 @@ private struct HUDAndOSDSettingsView: View { VStack(spacing: 20) { HStack(spacing: 16) { HUDSelectionCard( - title: "Dynamic Island", + title: String(localized: "Dynamic Island"), isSelected: selectedTab == .hud, action: { selectedTab = .hud @@ -1649,7 +1649,7 @@ private struct HUDAndOSDSettingsView: View { } HUDSelectionCard( - title: "Custom OSD", + title: String(localized: "Custom OSD"), isSelected: selectedTab == .osd, action: { selectedTab = .osd @@ -1691,7 +1691,7 @@ private struct HUDAndOSDSettingsView: View { } HUDSelectionCard( - title: "Vertical Bar", + title: String(localized: "Vertical Bar"), isSelected: selectedTab == .vertical, action: { selectedTab = .vertical @@ -1734,7 +1734,7 @@ private struct HUDAndOSDSettingsView: View { } HUDSelectionCard( - title: "Circular", + title: String(localized: "Circular"), isSelected: selectedTab == .circular, action: { selectedTab = .circular @@ -2527,9 +2527,11 @@ struct Media: View { } } if showShuffleAndRepeat { - Defaults.Toggle("Show \"Change Media Output\" control", key: .showMediaOutputControl) - .settingsHighlight(id: highlightID("Show Change Media Output control")) - .help("Adds the AirPlay/route picker button back to the customizable controls palette.") + Defaults.Toggle(key: .showMediaOutputControl) { + Text("Show \"Change Media Output\" control") + } + .settingsHighlight(id: highlightID("Show Change Media Output control")) + .help("Adds the AirPlay/route picker button back to the customizable controls palette.") MusicSlotConfigurationView() } else { Text("Turn on customizable controls to rearrange media buttons.") @@ -2574,10 +2576,9 @@ struct Media: View { ) .disabled(standardControlsSuppressed) .help(standardControlsSuppressed ? "Standard notch media controls are hidden while this toggle is off." : "") - Defaults.Toggle( - "Show floating media controls", - key: .musicControlWindowEnabled - ) + Defaults.Toggle(key: .musicControlWindowEnabled) { + Text("Show floating media controls") + } .disabled(!coordinator.musicLiveActivityEnabled || standardControlsSuppressed) .help("Displays play/pause and skip buttons beside the notch while music is active. Disabled by default.") Toggle("Enable sneak peek", isOn: $enableSneakPeek) @@ -2622,9 +2623,11 @@ struct Media: View { } .disabled(!enableLockScreenMediaWidget) if isAppleMusicActive { - Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput) - .disabled(!enableLockScreenMediaWidget) - .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) + Defaults.Toggle(key: .lockScreenMusicMergedAirPlayOutput) { + Text("Show merged AirPlay and output devices") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) } Defaults.Toggle(key: .lockScreenPanelShowsBorder) { Text("Show panel border") @@ -2974,10 +2977,9 @@ struct CalendarSettings: View { } } - Defaults.Toggle( - "Hide active event and show next upcoming event", - key: .lockScreenShowCalendarEventAfterStartEnabled - ) + Defaults.Toggle(key: .lockScreenShowCalendarEventAfterStartEnabled) { + Text("Hide active event and show next upcoming event") + } .disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration) .settingsHighlight(id: highlightID("Hide active event and show next upcoming event")) @@ -3076,12 +3078,12 @@ struct CalendarSettings: View { private func statusText(for status: EKAuthorizationStatus) -> String { switch status { - case .fullAccess, .authorized: return "Full Access" - case .writeOnly: return "Write Only" - case .denied: return "Denied" - case .restricted: return "Restricted" - case .notDetermined: return "Not Determined" - @unknown default: return "Unknown" + case .fullAccess, .authorized: return String(localized: "Full Access") + case .writeOnly: return String(localized: "Write Only") + case .denied: return String(localized: "Denied") + case .restricted: return String(localized: "Restricted") + case .notDetermined: return String(localized: "Not Determined") + @unknown default: return String(localized: "Unknown") } } @@ -3553,12 +3555,12 @@ struct LiveActivitiesSettings: View { Section { if !fullDiskAccessPermission.isAuthorized { SettingsPermissionCallout( - title: "Custom Focus metadata", - message: "Full Disk Access unlocks custom Focus icons, colors, and labels. Standard Focus detection still works without it—grant access only if you need personalized indicators.", + title: String(localized: "Custom Focus metadata"), + message: String(localized: "Full Disk Access unlocks custom Focus icons, colors, and labels. Standard Focus detection still works without it—grant access only if you need personalized indicators."), icon: "externaldrive.fill", iconColor: .purple, - requestButtonTitle: "Request Full Disk Access", - openSettingsButtonTitle: "Open Privacy & Security", + requestButtonTitle: String(localized: "Request Full Disk Access"), + openSettingsButtonTitle: String(localized: "Open Privacy & Security"), requestAction: { fullDiskAccessPermission.requestAccessPrompt() }, openSettingsAction: { fullDiskAccessPermission.openSystemSettings() } ) @@ -3938,8 +3940,9 @@ struct Appearance: View { Text("Enable colored spectrograms") } .settingsHighlight(id: highlightID("Enable colored spectrograms")) - Defaults - .Toggle("Player tinting", key: .playerColorTinting) + Defaults.Toggle(key: .playerColorTinting) { + Text("Enable colored spectograms") + } Defaults.Toggle(key: .lightingEffect) { Text("Enable blur effect behind album art") } @@ -4403,8 +4406,8 @@ struct Appearance: View { } let description = enableMinimalisticUI - ? "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value." - : "Recommended minimum width adjusts automatically based on the number of enabled tabs." + ? String(localized: "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value.") + : String(localized: "Recommended minimum width adjusts automatically based on the number of enabled tabs.") Text(description) .font(.caption) @@ -4638,9 +4641,11 @@ struct LockScreenSettings: View { .disabled(!enableLockScreenMediaWidget) .settingsHighlight(id: highlightID("Show media app icon")) if isAppleMusicActive { - Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput) - .disabled(!enableLockScreenMediaWidget) - .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) + Defaults.Toggle(key: .lockScreenMusicMergedAirPlayOutput) { + Text("Show merged AirPlay and output devices") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) } Defaults.Toggle(key: .lockScreenPanelShowsBorder) { Text("Show panel border") @@ -5248,9 +5253,9 @@ private struct LockScreenPositioningControls: View { HStack(alignment: .top, spacing: 24) { offsetColumn( - title: "Weather", + title: String(localized: "Weather"), value: weatherOffset, - resetTitle: "Reset Weather", + resetTitle: String(localized: "Reset Weather"), resetAction: resetWeatherOffset ) @@ -5258,9 +5263,9 @@ private struct LockScreenPositioningControls: View { .frame(height: 64) offsetColumn( - title: "Timer", + title: String(localized: "Timer"), value: timerOffset, - resetTitle: "Reset Timer", + resetTitle: String(localized: "Reset Timer"), resetAction: resetTimerOffset ) @@ -5268,9 +5273,9 @@ private struct LockScreenPositioningControls: View { .frame(height: 64) offsetColumn( - title: "Music", + title: String(localized: "Music"), value: musicOffset, - resetTitle: "Reset Music", + resetTitle: String(localized: "Reset Music"), resetAction: resetMusicOffset ) @@ -5282,21 +5287,21 @@ private struct LockScreenPositioningControls: View { VStack(alignment: .leading, spacing: 16) { widthSlider( - title: "Media Panel Width", + title: String(localized: "Media Panel Width"), value: musicWidthBinding, range: musicWidthRange, - resetTitle: "Reset Media Width", + resetTitle: String(localized: "Reset Media Width"), resetAction: resetMusicWidth, - helpText: "Shrinks the lock screen media panel while keeping the expanded view full width." + helpText: String(localized: "Shrinks the lock screen media panel while keeping the expanded view full width.") ) widthSlider( - title: "Timer Widget Width", + title: String(localized: "Timer Widget Width"), value: timerWidthBinding, range: timerWidthRange, - resetTitle: "Reset Timer Width", + resetTitle: String(localized: "Reset Timer Width"), resetAction: resetTimerWidth, - helpText: "Adjusts the lock screen timer widget width without affecting button sizing." + helpText: String(localized: "Adjusts the lock screen timer widget width without affecting button sizing.") ) } } header: { @@ -6136,9 +6141,9 @@ struct TimerSettings: View { Text("Default Custom Timer") .font(.headline) - TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23) - TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59) - TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59) + TimerDurationStepperRow(title: String(localized: "Hours"), value: $customHours, range: 0...23) + TimerDurationStepperRow(title: String(localized: "Minutes"), value: $customMinutes, range: 0...59) + TimerDurationStepperRow(title: String(localized: "Seconds"), value: $customSeconds, range: 0...59) HStack { Text("Current default:") @@ -6478,9 +6483,9 @@ private struct TimerPresetEditorRow: View { } HStack(spacing: 16) { - TimerPresetComponentControl(title: "Hours", value: hoursBinding, range: 0...23) - TimerPresetComponentControl(title: "Minutes", value: minutesBinding, range: 0...59) - TimerPresetComponentControl(title: "Seconds", value: secondsBinding, range: 0...59) + TimerPresetComponentControl(title: String(localized: "Hours"), value: hoursBinding, range: 0...23) + TimerPresetComponentControl(title: String(localized: "Minutes"), value: minutesBinding, range: 0...59) + TimerPresetComponentControl(title: String(localized: "Seconds"), value: secondsBinding, range: 0...59) } ColorPicker("Accent colour", selection: colorBinding, supportsOpacity: false) diff --git a/DynamicIsland/components/Stats/CPUStatsSharedViews.swift b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift index 29b96be..773a788 100644 --- a/DynamicIsland/components/Stats/CPUStatsSharedViews.swift +++ b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift @@ -77,10 +77,10 @@ struct CPUUsageDashboard: View { Text("Usage") .font(.caption) .foregroundColor(.secondary) - DetailRow(color: systemColor.opacity(0.95), label: "System", value: StatsFormatting.percentage(breakdown.system)) - DetailRow(color: userColor.opacity(0.95), label: "User", value: StatsFormatting.percentage(breakdown.user)) - DetailRow(color: idleColor.opacity(0.7), label: "Idle", value: StatsFormatting.percentage(breakdown.idle)) - DetailRow(color: nil, label: "Active", value: StatsFormatting.percentage(breakdown.activeUsage)) + DetailRow(color: systemColor.opacity(0.95), label: String(localized: "System"), value: StatsFormatting.percentage(breakdown.system)) + DetailRow(color: userColor.opacity(0.95), label: String(localized: "User"), value: StatsFormatting.percentage(breakdown.user)) + DetailRow(color: idleColor.opacity(0.7), label: String(localized: "Idle"), value: StatsFormatting.percentage(breakdown.idle)) + DetailRow(color: nil, label: String(localized: "stats_resource_active", defaultValue: "Active"), value: StatsFormatting.percentage(breakdown.activeUsage)) } .frame(maxWidth: .infinity, alignment: .leading) @@ -171,7 +171,7 @@ private struct CPUTemperatureGauge: View { var body: some View { CircularGaugeView( - title: "Temperature", + title: String(localized: "Temperature"), value: normalizedValue, tint: tint, centerPrimaryText: centerPrimary, diff --git a/DynamicIsland/components/Stats/GPUStatsDetailView.swift b/DynamicIsland/components/Stats/GPUStatsDetailView.swift index 064d2b5..1cac62a 100644 --- a/DynamicIsland/components/Stats/GPUStatsDetailView.swift +++ b/DynamicIsland/components/Stats/GPUStatsDetailView.swift @@ -133,7 +133,7 @@ private struct GPUUsageDashboard: View { .font(.caption) .foregroundColor(.secondary) DetailRow(color: nil, label: String(localized: "Session Avg"), value: StatsFormatting.percentage(averageUsage)) - DetailRow(color: nil, label: String("localized: Last Update"), value: formattedTimestamp(lastUpdated)) + DetailRow(color: nil, label: String(localized: "Last Update"), value: formattedTimestamp(lastUpdated)) if let device = primaryDevice { Divider().padding(.vertical, 4) DetailRow(color: nil, label: String(localized: "Active GPU"), value: device.formattedVendorModel) diff --git a/DynamicIsland/models/MusicControlButton.swift b/DynamicIsland/models/MusicControlButton.swift index 3d2bd23..fb5555b 100644 --- a/DynamicIsland/models/MusicControlButton.swift +++ b/DynamicIsland/models/MusicControlButton.swift @@ -76,27 +76,27 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S var label: String { switch self { case .shuffle: - return "Shuffle" + return String(localized: "Shuffle") case .trackBackward: - return "Previous Track" + return String(localized: "Previous Track") case .playPause: - return "Play / Pause" + return String(localized: "Play / Pause") case .trackForward: - return "Next Track" + return String(localized: "Next Track") case .repeatMode: - return "Repeat" + return String(localized: "Repeat") case .mediaOutput: - return "Change Media Output" + return String(localized: "Change Media Output") case .airPlay: - return "AirPlay" + return String(localized: "AirPlay") case .lyrics: - return "Lyrics" + return String(localized: "Lyrics") case .seekBackward: - return "Rewind 10s" + return String(localized: "Rewind 10s") case .seekForward: - return "Forward 10s" + return String(localized: "Forward 10s") case .none: - return "Empty Slot" + return String(localized: "Empty Slot") } }