mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge remote-tracking branch 'origin/main' into feat/localization-ru
This commit is contained in:
commit
6b1c3295e6
22 changed files with 2928 additions and 254 deletions
|
|
@ -27,7 +27,6 @@
|
||||||
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
|
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
|
||||||
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
|
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
|
||||||
7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; };
|
7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; };
|
||||||
7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
|
@ -54,7 +53,6 @@
|
||||||
7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = "<group>"; };
|
7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = "<group>"; };
|
||||||
7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; };
|
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; };
|
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; };
|
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; };
|
F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
@ -67,6 +65,13 @@
|
||||||
);
|
);
|
||||||
target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */;
|
target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */;
|
||||||
};
|
};
|
||||||
|
7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */;
|
||||||
|
};
|
||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
|
||||||
|
|
@ -94,6 +99,9 @@
|
||||||
};
|
};
|
||||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = {
|
F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */,
|
||||||
|
);
|
||||||
path = DynamicIsland;
|
path = DynamicIsland;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
|
@ -123,14 +131,6 @@
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
7E07AFF12F181892003A8B1B /* Recovered References */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */,
|
|
||||||
);
|
|
||||||
name = "Recovered References";
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = {
|
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -173,7 +173,6 @@
|
||||||
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
||||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
||||||
F54406E82CD4987A00C7AFD9 /* Products */,
|
F54406E82CD4987A00C7AFD9 /* Products */,
|
||||||
7E07AFF12F181892003A8B1B /* Recovered References */,
|
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
|
@ -296,6 +295,7 @@
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
7EFAA8AA2F6F12F3000A99E8 /* ShellScript */ = {
|
7EFAA8AA2F6F12F3000A99E8 /* ShellScript */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
alwaysOutOfDate = 1;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
|
|
@ -318,7 +318,6 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
@ -451,13 +450,11 @@
|
||||||
AUTOMATION_APPLE_EVENTS = NO;
|
AUTOMATION_APPLE_EVENTS = NO;
|
||||||
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CODE_SIGN_STYLE = Manual;
|
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 11;
|
CURRENT_PROJECT_VERSION = 11;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = 9Y64TRM77N;
|
||||||
"DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N;
|
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
|
|
||||||
21
DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json
vendored
Normal file
21
DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json
vendored
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png
vendored
Normal file
BIN
DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
|
|
@ -52,6 +52,7 @@ struct ContentView: View {
|
||||||
@ObservedObject var capsLockManager = CapsLockManager.shared
|
@ObservedObject var capsLockManager = CapsLockManager.shared
|
||||||
@ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared
|
@ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared
|
||||||
@ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
|
@ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
|
||||||
|
@ObservedObject var localSendService = LocalSendService.shared
|
||||||
@State private var downloadManager = DownloadManager.shared
|
@State private var downloadManager = DownloadManager.shared
|
||||||
|
|
||||||
@Default(.enableStatsFeature) var enableStatsFeature
|
@Default(.enableStatsFeature) var enableStatsFeature
|
||||||
|
|
@ -294,6 +295,19 @@ struct ContentView: View {
|
||||||
shouldHideUntilHover && !lockScreenManager.isLocked
|
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.
|
/// Pill shape for Dynamic Island mode with animated corner radius transitions.
|
||||||
private var currentPillShape: DynamicIslandPillShape {
|
private var currentPillShape: DynamicIslandPillShape {
|
||||||
let radius: CGFloat
|
let radius: CGFloat
|
||||||
|
|
@ -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 {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed {
|
||||||
DownloadLiveActivity()
|
DownloadLiveActivity()
|
||||||
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
|
.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 {
|
} 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()
|
DoNotDisturbLiveActivity()
|
||||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed {
|
||||||
|
|
@ -1549,7 +1566,6 @@ struct ContentView: View {
|
||||||
coordinator.currentView = .shelf
|
coordinator.currentView = .shelf
|
||||||
openNotch()
|
openNotch()
|
||||||
} else if !isTargeted {
|
} else if !isTargeted {
|
||||||
print("DROP EVENT", vm.dropEvent)
|
|
||||||
if vm.dropEvent {
|
if vm.dropEvent {
|
||||||
vm.dropEvent = false
|
vm.dropEvent = false
|
||||||
return
|
return
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -272,7 +272,7 @@ extension EKCalendar {
|
||||||
var accountTitle: String {
|
var accountTitle: String {
|
||||||
switch source.sourceType {
|
switch source.sourceType {
|
||||||
case .local, .subscribed, .birthdays:
|
case .local, .subscribed, .birthdays:
|
||||||
return "Other"
|
return String(localized: "Other")
|
||||||
default:
|
default:
|
||||||
return source.title
|
return source.title
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: "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: "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: "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
|
// 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")),
|
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")),
|
||||||
|
|
@ -1609,7 +1610,7 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 20) {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
HUDSelectionCard(
|
HUDSelectionCard(
|
||||||
title: "Dynamic Island",
|
title: String(localized: "Dynamic Island"),
|
||||||
isSelected: selectedTab == .hud,
|
isSelected: selectedTab == .hud,
|
||||||
action: {
|
action: {
|
||||||
selectedTab = .hud
|
selectedTab = .hud
|
||||||
|
|
@ -1649,7 +1650,7 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
HUDSelectionCard(
|
HUDSelectionCard(
|
||||||
title: "Custom OSD",
|
title: String(localized: "Custom OSD"),
|
||||||
isSelected: selectedTab == .osd,
|
isSelected: selectedTab == .osd,
|
||||||
action: {
|
action: {
|
||||||
selectedTab = .osd
|
selectedTab = .osd
|
||||||
|
|
@ -1691,7 +1692,7 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
HUDSelectionCard(
|
HUDSelectionCard(
|
||||||
title: "Vertical Bar",
|
title: String(localized: "Vertical Bar"),
|
||||||
isSelected: selectedTab == .vertical,
|
isSelected: selectedTab == .vertical,
|
||||||
action: {
|
action: {
|
||||||
selectedTab = .vertical
|
selectedTab = .vertical
|
||||||
|
|
@ -1734,7 +1735,7 @@ private struct HUDAndOSDSettingsView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
HUDSelectionCard(
|
HUDSelectionCard(
|
||||||
title: "Circular",
|
title: String(localized: "Circular"),
|
||||||
isSelected: selectedTab == .circular,
|
isSelected: selectedTab == .circular,
|
||||||
action: {
|
action: {
|
||||||
selectedTab = .circular
|
selectedTab = .circular
|
||||||
|
|
@ -2527,9 +2528,11 @@ struct Media: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if showShuffleAndRepeat {
|
if showShuffleAndRepeat {
|
||||||
Defaults.Toggle("Show \"Change Media Output\" control", key: .showMediaOutputControl)
|
Defaults.Toggle(key: .showMediaOutputControl) {
|
||||||
.settingsHighlight(id: highlightID("Show Change Media Output control"))
|
Text("Show \"Change Media Output\" control")
|
||||||
.help("Adds the AirPlay/route picker button back to the customizable controls palette.")
|
}
|
||||||
|
.settingsHighlight(id: highlightID("Show Change Media Output control"))
|
||||||
|
.help("Adds the AirPlay/route picker button back to the customizable controls palette.")
|
||||||
MusicSlotConfigurationView()
|
MusicSlotConfigurationView()
|
||||||
} else {
|
} else {
|
||||||
Text("Turn on customizable controls to rearrange media buttons.")
|
Text("Turn on customizable controls to rearrange media buttons.")
|
||||||
|
|
@ -2574,10 +2577,9 @@ struct Media: View {
|
||||||
)
|
)
|
||||||
.disabled(standardControlsSuppressed)
|
.disabled(standardControlsSuppressed)
|
||||||
.help(standardControlsSuppressed ? "Standard notch media controls are hidden while this toggle is off." : "")
|
.help(standardControlsSuppressed ? "Standard notch media controls are hidden while this toggle is off." : "")
|
||||||
Defaults.Toggle(
|
Defaults.Toggle(key: .musicControlWindowEnabled) {
|
||||||
"Show floating media controls",
|
Text("Show floating media controls")
|
||||||
key: .musicControlWindowEnabled
|
}
|
||||||
)
|
|
||||||
.disabled(!coordinator.musicLiveActivityEnabled || standardControlsSuppressed)
|
.disabled(!coordinator.musicLiveActivityEnabled || standardControlsSuppressed)
|
||||||
.help("Displays play/pause and skip buttons beside the notch while music is active. Disabled by default.")
|
.help("Displays play/pause and skip buttons beside the notch while music is active. Disabled by default.")
|
||||||
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
|
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
|
||||||
|
|
@ -2622,9 +2624,11 @@ struct Media: View {
|
||||||
}
|
}
|
||||||
.disabled(!enableLockScreenMediaWidget)
|
.disabled(!enableLockScreenMediaWidget)
|
||||||
if isAppleMusicActive {
|
if isAppleMusicActive {
|
||||||
Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput)
|
Defaults.Toggle(key: .lockScreenMusicMergedAirPlayOutput) {
|
||||||
.disabled(!enableLockScreenMediaWidget)
|
Text("Show merged AirPlay and output devices")
|
||||||
.settingsHighlight(id: highlightID("Show merged AirPlay and output devices"))
|
}
|
||||||
|
.disabled(!enableLockScreenMediaWidget)
|
||||||
|
.settingsHighlight(id: highlightID("Show merged AirPlay and output devices"))
|
||||||
}
|
}
|
||||||
Defaults.Toggle(key: .lockScreenPanelShowsBorder) {
|
Defaults.Toggle(key: .lockScreenPanelShowsBorder) {
|
||||||
Text("Show panel border")
|
Text("Show panel border")
|
||||||
|
|
@ -2974,10 +2978,9 @@ struct CalendarSettings: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Defaults.Toggle(
|
Defaults.Toggle(key: .lockScreenShowCalendarEventAfterStartEnabled) {
|
||||||
"Hide active event and show next upcoming event",
|
Text("Hide active event and show next upcoming event")
|
||||||
key: .lockScreenShowCalendarEventAfterStartEnabled
|
}
|
||||||
)
|
|
||||||
.disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration)
|
.disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration)
|
||||||
.settingsHighlight(id: highlightID("Hide active event and show next upcoming event"))
|
.settingsHighlight(id: highlightID("Hide active event and show next upcoming event"))
|
||||||
|
|
||||||
|
|
@ -3076,12 +3079,12 @@ struct CalendarSettings: View {
|
||||||
|
|
||||||
private func statusText(for status: EKAuthorizationStatus) -> String {
|
private func statusText(for status: EKAuthorizationStatus) -> String {
|
||||||
switch status {
|
switch status {
|
||||||
case .fullAccess, .authorized: return "Full Access"
|
case .fullAccess, .authorized: return String(localized: "Full Access")
|
||||||
case .writeOnly: return "Write Only"
|
case .writeOnly: return String(localized: "Write Only")
|
||||||
case .denied: return "Denied"
|
case .denied: return String(localized: "Denied")
|
||||||
case .restricted: return "Restricted"
|
case .restricted: return String(localized: "Restricted")
|
||||||
case .notDetermined: return "Not Determined"
|
case .notDetermined: return String(localized: "Not Determined")
|
||||||
@unknown default: return "Unknown"
|
@unknown default: return String(localized: "Unknown")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3438,12 +3441,14 @@ struct Shelf: View {
|
||||||
if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) {
|
if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) {
|
||||||
Image(nsImage: nsImg)
|
Image(nsImage: nsImg)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.scaledToFit()
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 16, height: 16)
|
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
Text(provider.id)
|
Text(provider.id)
|
||||||
}
|
}
|
||||||
|
|
@ -3459,12 +3464,14 @@ struct Shelf: View {
|
||||||
if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) {
|
if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) {
|
||||||
Image(nsImage: nsImg)
|
Image(nsImage: nsImg)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.scaledToFit()
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 16, height: 16)
|
|
||||||
.foregroundColor(.accentColor)
|
.foregroundColor(.accentColor)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Currently selected: \(selectedProvider.id)")
|
Text("Currently selected: \(selectedProvider.id)")
|
||||||
|
|
@ -3486,6 +3493,10 @@ struct Shelf: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if quickShareProvider == "LocalSend" {
|
||||||
|
LocalSendSettingsSection(highlightID: highlightID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.accentColor(.effectiveAccent)
|
.accentColor(.effectiveAccent)
|
||||||
.navigationTitle("Shelf")
|
.navigationTitle("Shelf")
|
||||||
|
|
@ -3496,6 +3507,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 {
|
struct LiveActivitiesSettings: View {
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@ObservedObject var recordingManager = ScreenRecordingManager.shared
|
@ObservedObject var recordingManager = ScreenRecordingManager.shared
|
||||||
|
|
@ -3553,12 +3599,12 @@ struct LiveActivitiesSettings: View {
|
||||||
Section {
|
Section {
|
||||||
if !fullDiskAccessPermission.isAuthorized {
|
if !fullDiskAccessPermission.isAuthorized {
|
||||||
SettingsPermissionCallout(
|
SettingsPermissionCallout(
|
||||||
title: "Custom Focus metadata",
|
title: String(localized: "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.",
|
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",
|
icon: "externaldrive.fill",
|
||||||
iconColor: .purple,
|
iconColor: .purple,
|
||||||
requestButtonTitle: "Request Full Disk Access",
|
requestButtonTitle: String(localized: "Request Full Disk Access"),
|
||||||
openSettingsButtonTitle: "Open Privacy & Security",
|
openSettingsButtonTitle: String(localized: "Open Privacy & Security"),
|
||||||
requestAction: { fullDiskAccessPermission.requestAccessPrompt() },
|
requestAction: { fullDiskAccessPermission.requestAccessPrompt() },
|
||||||
openSettingsAction: { fullDiskAccessPermission.openSystemSettings() }
|
openSettingsAction: { fullDiskAccessPermission.openSystemSettings() }
|
||||||
)
|
)
|
||||||
|
|
@ -3938,8 +3984,9 @@ struct Appearance: View {
|
||||||
Text("Enable colored spectrograms")
|
Text("Enable colored spectrograms")
|
||||||
}
|
}
|
||||||
.settingsHighlight(id: highlightID("Enable colored spectrograms"))
|
.settingsHighlight(id: highlightID("Enable colored spectrograms"))
|
||||||
Defaults
|
Defaults.Toggle(key: .playerColorTinting) {
|
||||||
.Toggle("Player tinting", key: .playerColorTinting)
|
Text("Enable colored spectograms")
|
||||||
|
}
|
||||||
Defaults.Toggle(key: .lightingEffect) {
|
Defaults.Toggle(key: .lightingEffect) {
|
||||||
Text("Enable blur effect behind album art")
|
Text("Enable blur effect behind album art")
|
||||||
}
|
}
|
||||||
|
|
@ -4403,8 +4450,8 @@ struct Appearance: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
let description = enableMinimalisticUI
|
let description = enableMinimalisticUI
|
||||||
? "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value."
|
? String(localized: "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: "Recommended minimum width adjusts automatically based on the number of enabled tabs.")
|
||||||
|
|
||||||
Text(description)
|
Text(description)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
@ -4638,9 +4685,11 @@ struct LockScreenSettings: View {
|
||||||
.disabled(!enableLockScreenMediaWidget)
|
.disabled(!enableLockScreenMediaWidget)
|
||||||
.settingsHighlight(id: highlightID("Show media app icon"))
|
.settingsHighlight(id: highlightID("Show media app icon"))
|
||||||
if isAppleMusicActive {
|
if isAppleMusicActive {
|
||||||
Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput)
|
Defaults.Toggle(key: .lockScreenMusicMergedAirPlayOutput) {
|
||||||
.disabled(!enableLockScreenMediaWidget)
|
Text("Show merged AirPlay and output devices")
|
||||||
.settingsHighlight(id: highlightID("Show merged AirPlay and output devices"))
|
}
|
||||||
|
.disabled(!enableLockScreenMediaWidget)
|
||||||
|
.settingsHighlight(id: highlightID("Show merged AirPlay and output devices"))
|
||||||
}
|
}
|
||||||
Defaults.Toggle(key: .lockScreenPanelShowsBorder) {
|
Defaults.Toggle(key: .lockScreenPanelShowsBorder) {
|
||||||
Text("Show panel border")
|
Text("Show panel border")
|
||||||
|
|
@ -5248,9 +5297,9 @@ private struct LockScreenPositioningControls: View {
|
||||||
|
|
||||||
HStack(alignment: .top, spacing: 24) {
|
HStack(alignment: .top, spacing: 24) {
|
||||||
offsetColumn(
|
offsetColumn(
|
||||||
title: "Weather",
|
title: String(localized: "Weather"),
|
||||||
value: weatherOffset,
|
value: weatherOffset,
|
||||||
resetTitle: "Reset Weather",
|
resetTitle: String(localized: "Reset Weather"),
|
||||||
resetAction: resetWeatherOffset
|
resetAction: resetWeatherOffset
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -5258,9 +5307,9 @@ private struct LockScreenPositioningControls: View {
|
||||||
.frame(height: 64)
|
.frame(height: 64)
|
||||||
|
|
||||||
offsetColumn(
|
offsetColumn(
|
||||||
title: "Timer",
|
title: String(localized: "Timer"),
|
||||||
value: timerOffset,
|
value: timerOffset,
|
||||||
resetTitle: "Reset Timer",
|
resetTitle: String(localized: "Reset Timer"),
|
||||||
resetAction: resetTimerOffset
|
resetAction: resetTimerOffset
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -5268,9 +5317,9 @@ private struct LockScreenPositioningControls: View {
|
||||||
.frame(height: 64)
|
.frame(height: 64)
|
||||||
|
|
||||||
offsetColumn(
|
offsetColumn(
|
||||||
title: "Music",
|
title: String(localized: "Music"),
|
||||||
value: musicOffset,
|
value: musicOffset,
|
||||||
resetTitle: "Reset Music",
|
resetTitle: String(localized: "Reset Music"),
|
||||||
resetAction: resetMusicOffset
|
resetAction: resetMusicOffset
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -5282,21 +5331,21 @@ private struct LockScreenPositioningControls: View {
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
widthSlider(
|
widthSlider(
|
||||||
title: "Media Panel Width",
|
title: String(localized: "Media Panel Width"),
|
||||||
value: musicWidthBinding,
|
value: musicWidthBinding,
|
||||||
range: musicWidthRange,
|
range: musicWidthRange,
|
||||||
resetTitle: "Reset Media Width",
|
resetTitle: String(localized: "Reset Media Width"),
|
||||||
resetAction: resetMusicWidth,
|
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(
|
widthSlider(
|
||||||
title: "Timer Widget Width",
|
title: String(localized: "Timer Widget Width"),
|
||||||
value: timerWidthBinding,
|
value: timerWidthBinding,
|
||||||
range: timerWidthRange,
|
range: timerWidthRange,
|
||||||
resetTitle: "Reset Timer Width",
|
resetTitle: String(localized: "Reset Timer Width"),
|
||||||
resetAction: resetTimerWidth,
|
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: {
|
} header: {
|
||||||
|
|
@ -6136,9 +6185,9 @@ struct TimerSettings: View {
|
||||||
Text("Default Custom Timer")
|
Text("Default Custom Timer")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
|
|
||||||
TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23)
|
TimerDurationStepperRow(title: String(localized: "Hours"), value: $customHours, range: 0...23)
|
||||||
TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59)
|
TimerDurationStepperRow(title: String(localized: "Minutes"), value: $customMinutes, range: 0...59)
|
||||||
TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59)
|
TimerDurationStepperRow(title: String(localized: "Seconds"), value: $customSeconds, range: 0...59)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("Current default:")
|
Text("Current default:")
|
||||||
|
|
@ -6478,9 +6527,9 @@ private struct TimerPresetEditorRow: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
TimerPresetComponentControl(title: "Hours", value: hoursBinding, range: 0...23)
|
TimerPresetComponentControl(title: String(localized: "Hours"), value: hoursBinding, range: 0...23)
|
||||||
TimerPresetComponentControl(title: "Minutes", value: minutesBinding, range: 0...59)
|
TimerPresetComponentControl(title: String(localized: "Minutes"), value: minutesBinding, range: 0...59)
|
||||||
TimerPresetComponentControl(title: "Seconds", value: secondsBinding, range: 0...59)
|
TimerPresetComponentControl(title: String(localized: "Seconds"), value: secondsBinding, range: 0...59)
|
||||||
}
|
}
|
||||||
|
|
||||||
ColorPicker("Accent colour", selection: colorBinding, supportsOpacity: false)
|
ColorPicker("Accent colour", selection: colorBinding, supportsOpacity: false)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import AppKit
|
||||||
|
import Defaults
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
|
@ -24,6 +26,10 @@ struct AirDropView: View {
|
||||||
|
|
||||||
@State var trigger: UUID = .init()
|
@State var trigger: UUID = .init()
|
||||||
@State var targeting = false
|
@State var targeting = false
|
||||||
|
@Default(.quickShareProvider) var quickShareProvider
|
||||||
|
@StateObject private var quickShareService = QuickShareService.shared
|
||||||
|
@State private var showShareSettings = false
|
||||||
|
@State private var isSwitchHover = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
dropArea
|
dropArea
|
||||||
|
|
@ -40,7 +46,103 @@ struct AirDropView: View {
|
||||||
.fill(.white.opacity(0.1))
|
.fill(.white.opacity(0.1))
|
||||||
.opacity(0.5)
|
.opacity(0.5)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
.overlay { dropLabel }
|
.overlay {
|
||||||
|
ZStack {
|
||||||
|
dropLabel
|
||||||
|
|
||||||
|
// Switch button in the top-right for quick-share settings
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
quickShareService.ensureDiscovered()
|
||||||
|
showShareSettings.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "switch.2")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
.padding(8)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(isSwitchHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear)
|
||||||
|
)
|
||||||
|
.foregroundColor(isSwitchHover ? .accentColor : .gray)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onHover { over in isSwitchHover = 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()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||||
|
} 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()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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(.trailing, 8)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
.aspectRatio(1, contentMode: .fit)
|
.aspectRatio(1, contentMode: .fit)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1117
DynamicIsland/components/Shelf/Services/LocalSendService.swift
Normal file
1117
DynamicIsland/components/Shelf/Services/LocalSendService.swift
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -129,6 +129,10 @@ class QuickShareService: ObservableObject {
|
||||||
providers.insert(ad, at: 0)
|
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
|
// System Share Menu fallback
|
||||||
if !providers.contains(where: { $0.id == "System Share Menu" }) {
|
if !providers.contains(where: { $0.id == "System Share Menu" }) {
|
||||||
providers.append(QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true))
|
providers.append(QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true))
|
||||||
|
|
@ -137,12 +141,37 @@ class QuickShareService: ObservableObject {
|
||||||
return (providers, services)
|
return (providers, services)
|
||||||
}.value
|
}.value
|
||||||
|
|
||||||
|
var providers = result.providers
|
||||||
|
if let idx = providers.firstIndex(where: { $0.id == "LocalSend" }) {
|
||||||
|
providers[idx].imageData = localSendIconData()
|
||||||
|
}
|
||||||
|
|
||||||
self.cachedServices = result.services
|
self.cachedServices = result.services
|
||||||
self.availableProviders = result.providers
|
self.availableProviders = providers
|
||||||
self.hasDiscovered = true
|
self.hasDiscovered = true
|
||||||
self.discoveryTask = nil
|
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
|
// MARK: - File Picker
|
||||||
@MainActor
|
@MainActor
|
||||||
func showFilePicker(for provider: QuickShareProvider, from view: NSView?) async {
|
func showFilePicker(for provider: QuickShareProvider, from view: NSView?) async {
|
||||||
|
|
@ -154,6 +183,9 @@ class QuickShareService: ObservableObject {
|
||||||
isPickerOpen = true
|
isPickerOpen = true
|
||||||
SharingStateManager.shared.beginInteraction()
|
SharingStateManager.shared.beginInteraction()
|
||||||
|
|
||||||
|
// Improve interaction in dev/test builds where app activation can be flaky.
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
let panel = NSOpenPanel()
|
let panel = NSOpenPanel()
|
||||||
panel.allowsMultipleSelection = true
|
panel.allowsMultipleSelection = true
|
||||||
panel.canChooseDirectories = true
|
panel.canChooseDirectories = true
|
||||||
|
|
@ -174,8 +206,12 @@ class QuickShareService: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = panel.runModal()
|
if let window = view?.window {
|
||||||
completion(response)
|
panel.beginSheetModal(for: window, completionHandler: completion)
|
||||||
|
} else {
|
||||||
|
let response = panel.runModal()
|
||||||
|
completion(response)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sharing
|
// MARK: - Sharing
|
||||||
|
|
@ -187,6 +223,21 @@ class QuickShareService: ObservableObject {
|
||||||
// Start security-scoped access for all file URLs
|
// Start security-scoped access for all file URLs
|
||||||
sharingAccessingURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() }
|
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
|
// Setup lifecycle delegate to keep notch open during picker/service
|
||||||
let delegate = SharingStateManager.shared.makeDelegate { [weak self] in
|
let delegate = SharingStateManager.shared.makeDelegate { [weak self] in
|
||||||
self?.lifecycleDelegate = nil
|
self?.lifecycleDelegate = nil
|
||||||
|
|
@ -210,6 +261,7 @@ class QuickShareService: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopSharingAccessingURLs() {
|
private func stopSharingAccessingURLs() {
|
||||||
|
guard !sharingAccessingURLs.isEmpty else { return }
|
||||||
NSLog("Stopping sharing access to URLs")
|
NSLog("Stopping sharing access to URLs")
|
||||||
for url in sharingAccessingURLs {
|
for url in sharingAccessingURLs {
|
||||||
url.stopAccessingSecurityScopedResource()
|
url.stopAccessingSecurityScopedResource()
|
||||||
|
|
|
||||||
|
|
@ -49,19 +49,25 @@ final class ShelfStateViewModel: ObservableObject {
|
||||||
var merged = items
|
var merged = items
|
||||||
// Deduplicate by identityKey while preserving order (existing first)
|
// Deduplicate by identityKey while preserving order (existing first)
|
||||||
var seen: Set<String> = Set(merged.map { $0.identityKey })
|
var seen: Set<String> = Set(merged.map { $0.identityKey })
|
||||||
|
var addedIDs: [String] = []
|
||||||
for it in newItems {
|
for it in newItems {
|
||||||
let key = it.identityKey
|
let key = it.identityKey
|
||||||
if !seen.contains(key) {
|
if !seen.contains(key) {
|
||||||
merged.append(it)
|
merged.append(it)
|
||||||
seen.insert(key)
|
seen.insert(key)
|
||||||
|
addedIDs.append(it.id.uuidString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items = merged
|
items = merged
|
||||||
|
if !addedIDs.isEmpty {
|
||||||
|
ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: addedIDs, action: "added")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove(_ item: ShelfItem) {
|
func remove(_ item: ShelfItem) {
|
||||||
item.cleanupStoredData()
|
item.cleanupStoredData()
|
||||||
items.removeAll { $0.id == item.id }
|
items.removeAll { $0.id == item.id }
|
||||||
|
ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: [item.id.uuidString], action: "removed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateBookmark(for item: ShelfItem, bookmark: Data) {
|
func updateBookmark(for item: ShelfItem, bookmark: Data) {
|
||||||
|
|
|
||||||
|
|
@ -28,29 +28,49 @@ import UniformTypeIdentifiers
|
||||||
struct FileShareView: View {
|
struct FileShareView: View {
|
||||||
@EnvironmentObject private var vm: DynamicIslandViewModel
|
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||||
@StateObject private var quickShare = QuickShareService.shared
|
@StateObject private var quickShare = QuickShareService.shared
|
||||||
|
@StateObject private var localSend = LocalSendService.shared
|
||||||
@Default(.quickShareProvider) var quickShareProvider: String
|
@Default(.quickShareProvider) var quickShareProvider: String
|
||||||
|
@State private var showQuickSharePopover = false
|
||||||
|
@State private var isSwitchHover = false
|
||||||
|
@State private var autoCloseToken = UUID()
|
||||||
|
|
||||||
@State private var hostView: NSView?
|
@State private var hostView: NSView?
|
||||||
@State private var interactionNonce: UUID = .init()
|
@State private var interactionNonce: UUID = .init()
|
||||||
@State private var isProcessing = false
|
@State private var isProcessing = false
|
||||||
|
@State private var pendingDropProviders: [NSItemProvider]?
|
||||||
|
@State private var showLocalSendPicker = false
|
||||||
|
|
||||||
private var selectedProvider: QuickShareProvider {
|
private var selectedProvider: QuickShareProvider {
|
||||||
quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)
|
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 {
|
var body: some View {
|
||||||
dropArea
|
dropArea
|
||||||
.background(NSViewHost(view: $hostView))
|
.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
|
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in
|
||||||
interactionNonce = .init()
|
interactionNonce = .init()
|
||||||
vm.dropEvent = true
|
vm.dropEvent = true
|
||||||
Task { await handleDrop(providers) }
|
if selectedProvider.id == "LocalSend" {
|
||||||
|
pendingDropProviders = providers
|
||||||
|
showLocalSendPicker = true
|
||||||
|
} else {
|
||||||
|
Task { await handleDrop(providers) }
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
Task {
|
guard quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) != nil else { return }
|
||||||
await handleClick()
|
// Only open picker on taps when AirDrop or LocalSend is selected
|
||||||
|
if quickShareProvider == "AirDrop" || quickShareProvider == "LocalSend" {
|
||||||
|
Task { await handleClick() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -85,12 +105,16 @@ struct FileShareView: View {
|
||||||
if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) {
|
if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) {
|
||||||
Image(nsImage: nsImg)
|
Image(nsImage: nsImg)
|
||||||
.resizable()
|
.resizable()
|
||||||
.aspectRatio(contentMode: .fit)
|
.scaledToFit()
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.clipped()
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: 34, height: 34)
|
|
||||||
.foregroundStyle(
|
.foregroundStyle(
|
||||||
vm.dropZoneTargeting ? Color.accentColor : Color.gray
|
vm.dropZoneTargeting ? Color.accentColor : Color.gray
|
||||||
)
|
)
|
||||||
|
|
@ -104,12 +128,117 @@ struct FileShareView: View {
|
||||||
.font(.system(.headline, design: .rounded))
|
.font(.system(.headline, design: .rounded))
|
||||||
.foregroundColor(.white.opacity(0.8))
|
.foregroundColor(.white.opacity(0.8))
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
}
|
}
|
||||||
.padding(18)
|
.padding(18)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
// Switch button pinned to top-right corner
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
vm.setAutoCloseSuppression(true, token: autoCloseToken)
|
||||||
|
quickShare.ensureDiscovered()
|
||||||
|
showQuickSharePopover.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "switch.2")
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
.frame(width: 14, height: 14)
|
||||||
|
.padding(8)
|
||||||
|
.background(RoundedRectangle(cornerRadius: 8).fill(isSwitchHover ? Color(.windowBackgroundColor).opacity(0.12) : Color.clear))
|
||||||
|
.foregroundColor(isSwitchHover ? .accentColor : .gray)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.onHover { hovering in
|
||||||
|
isSwitchHover = 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()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 3))
|
||||||
|
} 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()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||||
|
} else {
|
||||||
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.frame(width: 20, height: 20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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(.trailing, 8)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
// Loading overlay
|
// Loading overlay
|
||||||
if isProcessing || quickShare.isPickerOpen {
|
if isProcessing || quickShare.isPickerOpen {
|
||||||
RoundedRectangle(cornerRadius: 12)
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
|
@ -120,8 +249,63 @@ struct FileShareView: View {
|
||||||
.scaleEffect(0.8)
|
.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))
|
.contentShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.onChange(of: showLocalSendPicker) { _, show in
|
||||||
|
if show {
|
||||||
|
LocalSendDevicePickerWindowManager.shared.show(
|
||||||
|
onDeviceSelected: { device in
|
||||||
|
localSend.selectedDeviceID = device.id
|
||||||
|
if let providers = pendingDropProviders {
|
||||||
|
// Close the picker first since handleDrop will use quickShare properly
|
||||||
|
showLocalSendPicker = false
|
||||||
|
Task {
|
||||||
|
await handleDrop(providers)
|
||||||
|
pendingDropProviders = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss: {
|
||||||
|
showLocalSendPicker = false
|
||||||
|
pendingDropProviders = nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LocalSendDevicePickerWindowManager.shared.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
|
||||||
173
DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift
Normal file
173
DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -77,10 +77,10 @@ struct CPUUsageDashboard: View {
|
||||||
Text("Usage")
|
Text("Usage")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
DetailRow(color: systemColor.opacity(0.95), label: "System", value: StatsFormatting.percentage(breakdown.system))
|
DetailRow(color: systemColor.opacity(0.95), label: String(localized: "System"), value: StatsFormatting.percentage(breakdown.system))
|
||||||
DetailRow(color: userColor.opacity(0.95), label: "User", value: StatsFormatting.percentage(breakdown.user))
|
DetailRow(color: userColor.opacity(0.95), label: String(localized: "User"), value: StatsFormatting.percentage(breakdown.user))
|
||||||
DetailRow(color: idleColor.opacity(0.7), label: "Idle", value: StatsFormatting.percentage(breakdown.idle))
|
DetailRow(color: idleColor.opacity(0.7), label: String(localized: "Idle"), value: StatsFormatting.percentage(breakdown.idle))
|
||||||
DetailRow(color: nil, label: "Active", value: StatsFormatting.percentage(breakdown.activeUsage))
|
DetailRow(color: nil, label: String(localized: "stats_resource_active", defaultValue: "Active"), value: StatsFormatting.percentage(breakdown.activeUsage))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
|
@ -171,7 +171,7 @@ private struct CPUTemperatureGauge: View {
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
CircularGaugeView(
|
CircularGaugeView(
|
||||||
title: "Temperature",
|
title: String(localized: "Temperature"),
|
||||||
value: normalizedValue,
|
value: normalizedValue,
|
||||||
tint: tint,
|
tint: tint,
|
||||||
centerPrimaryText: centerPrimary,
|
centerPrimaryText: centerPrimary,
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ private struct GPUUsageDashboard: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
DetailRow(color: nil, label: String(localized: "Session Avg"), value: StatsFormatting.percentage(averageUsage))
|
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 {
|
if let device = primaryDevice {
|
||||||
Divider().padding(.vertical, 4)
|
Divider().padding(.vertical, 4)
|
||||||
DetailRow(color: nil, label: String(localized: "Active GPU"), value: device.formattedVendorModel)
|
DetailRow(color: nil, label: String(localized: "Active GPU"), value: device.formattedVendorModel)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
import AtollExtensionKit
|
import AtollExtensionKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Defaults
|
import Defaults
|
||||||
|
import AppKit
|
||||||
|
|
||||||
struct TabModel: Identifiable {
|
struct TabModel: Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
|
|
@ -45,6 +46,9 @@ struct TabModel: Identifiable {
|
||||||
struct TabSelectionView: View {
|
struct TabSelectionView: View {
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.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(.enableTimerFeature) var enableTimerFeature
|
||||||
@Default(.enableStatsFeature) var enableStatsFeature
|
@Default(.enableStatsFeature) var enableStatsFeature
|
||||||
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
||||||
|
|
@ -106,9 +110,11 @@ struct TabSelectionView: View {
|
||||||
}
|
}
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ForEach(tabs) { tab in
|
ForEach(Array(tabs.enumerated()), id: \.element.id) { idx, tab in
|
||||||
let isSelected = isSelected(tab)
|
let isSelected = isSelected(tab)
|
||||||
let activeAccent = tab.accentColor ?? .white
|
let activeAccent = tab.accentColor ?? .white
|
||||||
|
|
||||||
|
// Render the tab button
|
||||||
TabButton(label: tab.label, icon: tab.icon, selected: isSelected) {
|
TabButton(label: tab.label, icon: tab.icon, selected: isSelected) {
|
||||||
if tab.view == .extensionExperience {
|
if tab.view == .extensionExperience {
|
||||||
coordinator.selectedExtensionExperienceID = tab.experienceID
|
coordinator.selectedExtensionExperienceID = tab.experienceID
|
||||||
|
|
@ -130,6 +136,8 @@ struct TabSelectionView: View {
|
||||||
.hidden()
|
.hidden()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.smooth(duration: 0.3), value: coordinator.currentView)
|
.animation(.smooth(duration: 0.3), value: coordinator.currentView)
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,8 @@ final class ExtensionAuthorizationManager: ObservableObject {
|
||||||
record.widgetTimestamps.append(now)
|
record.widgetTimestamps.append(now)
|
||||||
case .notchExperiences:
|
case .notchExperiences:
|
||||||
record.notchExperienceTimestamps.append(now)
|
record.notchExperienceTimestamps.append(now)
|
||||||
|
case .fileSharing:
|
||||||
|
record.activityTimestamps.append(now)
|
||||||
}
|
}
|
||||||
record.activityTimestamps = flushOldTimestamps(record.activityTimestamps)
|
record.activityTimestamps = flushOldTimestamps(record.activityTimestamps)
|
||||||
record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps)
|
record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps)
|
||||||
|
|
|
||||||
561
DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift
Normal file
561
DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift
Normal file
|
|
@ -0,0 +1,561 @@
|
||||||
|
/*
|
||||||
|
* 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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
private var window: NSWindow?
|
||||||
|
private var onDeviceSelected: ((LocalSendDeviceInfo) -> Void)?
|
||||||
|
private var onDismiss: (() -> Void)?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
func show(
|
||||||
|
onDeviceSelected: @escaping (LocalSendDeviceInfo) -> Void,
|
||||||
|
onDismiss: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
let cornerRadius: CGFloat = 24
|
||||||
|
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 {
|
||||||
|
let hostingView = NSHostingView(rootView: pickerView)
|
||||||
|
applyLocalSendPanelCornerMask(hostingView, radius: cornerRadius)
|
||||||
|
existingWindow.contentView = hostingView
|
||||||
|
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]
|
||||||
|
|
||||||
|
let hostingView = NSHostingView(rootView:
|
||||||
|
pickerView
|
||||||
|
.background(Color.clear)
|
||||||
|
)
|
||||||
|
applyLocalSendPanelCornerMask(hostingView, radius: cornerRadius)
|
||||||
|
newWindow.contentView = hostingView
|
||||||
|
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 {
|
||||||
|
// Explicitly clear background for proper transparency
|
||||||
|
Color.clear
|
||||||
|
|
||||||
|
// Background - clips to rounded rect
|
||||||
|
panelBackground
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
.compositingGroup()
|
||||||
|
.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,
|
||||||
|
isRejected: localSend.rejectedDeviceIDs.contains(device.id),
|
||||||
|
onSelect: {
|
||||||
|
localSend.clearRejectedStatus(for: device.id)
|
||||||
|
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 isRejected: Bool
|
||||||
|
let onSelect: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onSelect) {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
// Device icon
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.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(isRejected ? .red : (isHovered ? .accentColor : .white.opacity(0.8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device name
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(device.alias)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundColor(isRejected ? .red : .white)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
|
||||||
|
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))
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 16)
|
||||||
|
.fill(isRejected ? Color.red.opacity(0.08) : 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 "smartphone"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -143,12 +143,14 @@ enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializa
|
||||||
case liveActivities
|
case liveActivities
|
||||||
case lockScreenWidgets
|
case lockScreenWidgets
|
||||||
case notchExperiences
|
case notchExperiences
|
||||||
|
case fileSharing
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .liveActivities: return "Live Activities"
|
case .liveActivities: return "Live Activities"
|
||||||
case .lockScreenWidgets: return "Lock Screen Widgets"
|
case .lockScreenWidgets: return "Lock Screen Widgets"
|
||||||
case .notchExperiences: return "Notch Experiences"
|
case .notchExperiences: return "Notch Experiences"
|
||||||
|
case .fileSharing: return "File Sharing"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -886,6 +888,9 @@ extension Defaults.Keys {
|
||||||
static let dynamicShelf = Key<Bool>("dynamicShelf", default: true)
|
static let dynamicShelf = Key<Bool>("dynamicShelf", default: true)
|
||||||
static let openShelfByDefault = Key<Bool>("openShelfByDefault", default: true)
|
static let openShelfByDefault = Key<Bool>("openShelfByDefault", default: true)
|
||||||
static let quickShareProvider = Key<String>("quickShareProvider", default: "AirDrop")
|
static let quickShareProvider = Key<String>("quickShareProvider", default: "AirDrop")
|
||||||
|
static let localSendSelectedDeviceID = Key<String>("localSendSelectedDeviceID", default: "")
|
||||||
|
static let localSendDevicePickerGlassMode = Key<LockScreenGlassCustomizationMode>("localSendDevicePickerGlassMode", default: .standard)
|
||||||
|
static let localSendDevicePickerLiquidGlassVariant = Key<LiquidGlassVariant>("localSendDevicePickerLiquidGlassVariant", default: .v11)
|
||||||
static let copyOnDrag = Key<Bool>("copyOnDrag", default: false)
|
static let copyOnDrag = Key<Bool>("copyOnDrag", default: false)
|
||||||
static let autoRemoveShelfItems = Key<Bool>("autoRemoveShelfItems", default: false)
|
static let autoRemoveShelfItems = Key<Bool>("autoRemoveShelfItems", default: false)
|
||||||
static let expandedDragDetection = Key<Bool>("expandedDragDetection", default: true)
|
static let expandedDragDetection = Key<Bool>("expandedDragDetection", default: true)
|
||||||
|
|
@ -1003,6 +1008,7 @@ extension Defaults.Keys {
|
||||||
static let extensionLiveActivityCapacity = Key<Int>("extensionLiveActivityCapacity", default: 4)
|
static let extensionLiveActivityCapacity = Key<Int>("extensionLiveActivityCapacity", default: 4)
|
||||||
static let extensionLockScreenWidgetCapacity = Key<Int>("extensionLockScreenWidgetCapacity", default: 4)
|
static let extensionLockScreenWidgetCapacity = Key<Int>("extensionLockScreenWidgetCapacity", default: 4)
|
||||||
static let extensionNotchExperienceCapacity = Key<Int>("extensionNotchExperienceCapacity", default: 2)
|
static let extensionNotchExperienceCapacity = Key<Int>("extensionNotchExperienceCapacity", default: 2)
|
||||||
|
static let enableExtensionFileSharing = Key<Bool>("enableExtensionFileSharing", default: true)
|
||||||
|
|
||||||
// MARK: Keyboard Shortcuts
|
// MARK: Keyboard Shortcuts
|
||||||
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true)
|
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true)
|
||||||
|
|
|
||||||
|
|
@ -76,27 +76,27 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
|
||||||
var label: String {
|
var label: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .shuffle:
|
case .shuffle:
|
||||||
return "Shuffle"
|
return String(localized: "Shuffle")
|
||||||
case .trackBackward:
|
case .trackBackward:
|
||||||
return "Previous Track"
|
return String(localized: "Previous Track")
|
||||||
case .playPause:
|
case .playPause:
|
||||||
return "Play / Pause"
|
return String(localized: "Play / Pause")
|
||||||
case .trackForward:
|
case .trackForward:
|
||||||
return "Next Track"
|
return String(localized: "Next Track")
|
||||||
case .repeatMode:
|
case .repeatMode:
|
||||||
return "Repeat"
|
return String(localized: "Repeat")
|
||||||
case .mediaOutput:
|
case .mediaOutput:
|
||||||
return "Change Media Output"
|
return String(localized: "Change Media Output")
|
||||||
case .airPlay:
|
case .airPlay:
|
||||||
return "AirPlay"
|
return String(localized: "AirPlay")
|
||||||
case .lyrics:
|
case .lyrics:
|
||||||
return "Lyrics"
|
return String(localized: "Lyrics")
|
||||||
case .seekBackward:
|
case .seekBackward:
|
||||||
return "Rewind 10s"
|
return String(localized: "Rewind 10s")
|
||||||
case .seekForward:
|
case .seekForward:
|
||||||
return "Forward 10s"
|
return String(localized: "Forward 10s")
|
||||||
case .none:
|
case .none:
|
||||||
return "Empty Slot"
|
return String(localized: "Empty Slot")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ final class ExtensionRPCServer {
|
||||||
|
|
||||||
private var listener: NWListener?
|
private var listener: NWListener?
|
||||||
private var connections: [UUID: RPCClientConnection] = [:]
|
private var connections: [UUID: RPCClientConnection] = [:]
|
||||||
|
private var shelfSubscribers: Set<String> = [] // bundleIdentifiers subscribed to shelf events
|
||||||
private let port: UInt16 = 9020
|
private let port: UInt16 = 9020
|
||||||
private let queue = DispatchQueue(label: "com.ebullioscopic.Atoll.rpc.server", qos: .userInitiated)
|
private let queue = DispatchQueue(label: "com.ebullioscopic.Atoll.rpc.server", qos: .userInitiated)
|
||||||
private let decoder = JSONDecoder()
|
private let decoder = JSONDecoder()
|
||||||
|
|
@ -78,6 +79,7 @@ final class ExtensionRPCServer {
|
||||||
conn.connection.cancel()
|
conn.connection.cancel()
|
||||||
}
|
}
|
||||||
connections.removeAll()
|
connections.removeAll()
|
||||||
|
shelfSubscribers.removeAll()
|
||||||
logDiagnostics("RPC server stopped")
|
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
|
// MARK: - Connection Handling
|
||||||
|
|
||||||
private func handleListenerState(_ state: NWListener.State) {
|
private func handleListenerState(_ state: NWListener.State) {
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import AppKit
|
||||||
import Defaults
|
import Defaults
|
||||||
import AtollExtensionKit
|
import AtollExtensionKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
/// Handles JSON-RPC method calls for a single WebSocket connection.
|
/// Handles JSON-RPC method calls for a single WebSocket connection.
|
||||||
/// Mirrors the functionality of `ExtensionXPCService` but uses JSON-RPC transport.
|
/// Mirrors the functionality of `ExtensionXPCService` but uses JSON-RPC transport.
|
||||||
|
|
@ -94,6 +96,25 @@ final class ExtensionRPCService {
|
||||||
case "atoll.dismissNotchExperience":
|
case "atoll.dismissNotchExperience":
|
||||||
result = handleDismissNotchExperience(params: request.params, id: request.id)
|
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:
|
default:
|
||||||
result = RPCErrorResponse(
|
result = RPCErrorResponse(
|
||||||
error: RPCErrorObject(code: RPCErrorCode.methodNotFound, message: "Method not found: \(request.method)"),
|
error: RPCErrorObject(code: RPCErrorCode.methodNotFound, message: "Method not found: \(request.method)"),
|
||||||
|
|
@ -327,7 +348,7 @@ final class ExtensionRPCService {
|
||||||
case .duplicateIdentifier: code = RPCErrorCode.descriptorInvalid
|
case .duplicateIdentifier: code = RPCErrorCode.descriptorInvalid
|
||||||
}
|
}
|
||||||
return RPCErrorResponse(
|
return RPCErrorResponse(
|
||||||
error: RPCErrorObject(code: code, message: error.localizedDescription ?? "Unknown error"),
|
error: RPCErrorObject(code: code, message: error.localizedDescription),
|
||||||
id: id
|
id: id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -337,6 +358,232 @@ final class ExtensionRPCService {
|
||||||
Logger.log(message, category: .extensions)
|
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
|
// MARK: - Client JSON Transformation
|
||||||
|
|
||||||
/// Converts client wire format to Swift Codable format for enum types.
|
/// Converts client wire format to Swift Codable format for enum types.
|
||||||
|
|
@ -452,3 +699,12 @@ final class ExtensionRPCService {
|
||||||
return [typeName: inner]
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue