diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 554f95a..021b45f 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -27,7 +27,6 @@ 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; }; 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; }; 7EF19C172F577040005B9929 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 7EF19C162F577040005B9929 /* SwiftTerm */; }; - 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -54,7 +53,6 @@ 7E9911AF2EFD7D1B002EDFD5 /* Reindeer.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = Reindeer.json; sourceTree = ""; }; 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; }; 7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; }; - 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ExtensionEventBridge.swift; path = DynamicIsland/managers/Extensions/ExtensionEventBridge.swift; sourceTree = SOURCE_ROOT; }; F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; }; F54406E72CD4987A00C7AFD9 /* Atoll.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Atoll.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -67,6 +65,13 @@ ); target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */; }; + 7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = F54406E62CD4987A00C7AFD9 /* DynamicIsland */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ @@ -94,6 +99,9 @@ }; F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7EA69B212F5B100000EB2D16 /* Exceptions for "DynamicIsland" folder in "DynamicIsland" target */, + ); path = DynamicIsland; sourceTree = ""; }; @@ -123,14 +131,6 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 7E07AFF12F181892003A8B1B /* Recovered References */ = { - isa = PBXGroup; - children = ( - 7EF1D4B82FFFCE3C008D2C32 /* ExtensionEventBridge.swift */, - ); - name = "Recovered References"; - sourceTree = ""; - }; 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = { isa = PBXGroup; children = ( @@ -173,7 +173,6 @@ 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */, F54406E92CD4987A00C7AFD9 /* DynamicIsland */, F54406E82CD4987A00C7AFD9 /* Products */, - 7E07AFF12F181892003A8B1B /* Recovered References */, ); sourceTree = ""; }; @@ -296,6 +295,7 @@ /* Begin PBXShellScriptBuildPhase section */ 7EFAA8AA2F6F12F3000A99E8 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -318,7 +318,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 7EF1D4B92FFFCE3C008D2C32 /* ExtensionEventBridge.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -451,13 +450,11 @@ AUTOMATION_APPLE_EVENTS = NO; CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application"; - CODE_SIGN_STYLE = Manual; + CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; - DEVELOPMENT_TEAM = ""; - "DEVELOPMENT_TEAM[sdk=macosx*]" = 9Y64TRM77N; + DEVELOPMENT_TEAM = 9Y64TRM77N; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json b/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json new file mode 100644 index 0000000..463ed06 --- /dev/null +++ b/DynamicIsland/Assets.xcassets/LocalSend.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "localsend.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png b/DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png new file mode 100644 index 0000000..5a5f3c1 Binary files /dev/null and b/DynamicIsland/Assets.xcassets/LocalSend.imageset/localsend.png differ diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 8909c88..c8059cd 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -52,6 +52,7 @@ struct ContentView: View { @ObservedObject var capsLockManager = CapsLockManager.shared @ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared @ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared + @ObservedObject var localSendService = LocalSendService.shared @State private var downloadManager = DownloadManager.shared @Default(.enableStatsFeature) var enableStatsFeature @@ -293,6 +294,19 @@ struct ContentView: View { private var shouldUseHiddenEdgeHoverPolling: Bool { shouldHideUntilHover && !lockScreenManager.isLocked } + + /// Whether the LocalSend live activity should be shown + private var localSendLiveActivityActive: Bool { + localSendService.isSending || + localSendService.transferState == .completed || + isLocalSendFailedOrRejected + } + + private var isLocalSendFailedOrRejected: Bool { + if case .failed = localSendService.transferState { return true } + if case .rejected = localSendService.transferState { return true } + return false + } /// Pill shape for Dynamic Island mode with animated corner radius transitions. private var currentPillShape: DynamicIslandPillShape { @@ -738,6 +752,9 @@ struct ContentView: View { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { DownloadLiveActivity() .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) + } else if !coordinator.expandingView.show && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed { + LocalSendLiveActivity() + .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked { DoNotDisturbLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { @@ -1549,7 +1566,6 @@ struct ContentView: View { coordinator.currentView = .shelf openNotch() } else if !isTargeted { - print("DROP EVENT", vm.dropEvent) if vm.dropEvent { vm.dropEvent = false return diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index ff10d7d..5a1344c 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -1550,7 +1550,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "%lldx" + "value": "%lldм назад" } } }, @@ -2148,7 +2148,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "1x" + "value": "1 мин" } } } @@ -2748,7 +2748,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Цилиндр акцента" + "value": "Выделяющий цвет" } }, "tr": { @@ -2872,7 +2872,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Доступ запрещен" + "value": "Доступ запрещён" } }, "tr": { @@ -3566,7 +3566,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Добавить новый визуализатор" + "value": "Добавить новую визуализацию" } }, "tr": { @@ -3797,6 +3797,7 @@ } } }, + "Adjusts the lock screen timer widget width without affecting button sizing.": {}, "AI Assistant": { "localizations": { "fr": { @@ -5036,7 +5037,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Иконка приложения" + "value": "Значок приложения" } }, "tr": { @@ -5282,7 +5283,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Вы уверены, что хотите удалить «%@»?" + "value": "AQI" } } } @@ -5792,7 +5793,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Автоматически загружать обновления" + "value": "Автоматически загружать уведомления" } }, "tr": { @@ -5900,7 +5901,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Подсветка" + "value": "Фон" } } } @@ -5994,7 +5995,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Аккумулятор" + "value": "Батарея" } }, "tr": { @@ -6432,7 +6433,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Состояние батареи" + "value": "Статус батареи" } }, "tr": { @@ -6536,7 +6537,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Синяя пунктирная рамка — реальный размер в Atoll" + "value": "Экранные индикаторы BetterDisplay будут отображаться в активном стиле панели Atoll. Убедитесь, что интеграция индикатора включена в BetterDisplay: Настройки › Приложение › Интеграция." } } } @@ -6619,7 +6620,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Устройство Bluetooth %1$@: %2$d%%" + "value": "Bluetooth-устройство %1$@ на %2$d %" } } } @@ -6717,7 +6718,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Менеджер буфера обмена" + "value": "Повысьте свою продуктивность с помощью менеджера буфера обмена." } }, "tr": { @@ -6824,7 +6825,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Детализация" + "value": "Перерыв" } } } @@ -6848,7 +6849,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Яркость" + "value": "Пауза" } } } @@ -7203,7 +7204,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Доступ к календарю запрещен. Разрешите его в Системных настройках." + "value": "Доступ к календарю запрещен. Пожалуйста, включите его в настройках системы." } }, "tr": { @@ -7301,7 +7302,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Календари" + "value": "Виджет календаря" } } } @@ -7628,6 +7629,7 @@ } } }, + "Change Media Output": {}, "Charging": { "comment": "Charging fallback label when no estimate is available", "localizations": { @@ -7824,7 +7826,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Зарядка приостановлена: настольный режим" + "value": "Зарядка приостановлена: Настольный режим" } }, "tr": { @@ -7924,7 +7926,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Проверить обновления…" + "value": "Проверить обновления..." } }, "tr": { @@ -8443,7 +8445,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Круговой" + "value": "Круг" } }, "tr": { @@ -8568,7 +8570,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Очистить" + "value": "Классическая форма выреза, плавно переходящая в верхний край экрана" } } } @@ -9302,7 +9304,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Соберите последний отчет о сбое для разработчика." + "value": "Разработка и отладка: цветовая пипетка, мониторинг статистики и экранный помощник." } } } @@ -9726,7 +9728,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Скоро" + "value": "Скоро будет" } }, "tr": { @@ -10504,7 +10506,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Использование CPU" + "value": "ЦП" } } } @@ -10740,6 +10742,10 @@ } } }, + "Currently: %@": { + "comment": "A label inside the quick share settings view, describing the currently selected quick share service.", + "isCommentAutoGenerated": true + }, "Cursor": { "localizations": { "ko": { @@ -10790,7 +10796,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Пользовательский" + "value": "Стиль указателя" } } } @@ -10819,11 +10825,12 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Свой цвет" + "value": "Пользовательский цвет" } } } }, + "Custom Focus metadata": {}, "Custom height": { "localizations": { "ar": { @@ -10901,7 +10908,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Своя высота" + "value": "Пользовательская высота" } }, "tr": { @@ -11095,7 +11102,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Своя анимация музыки в Live Activity" + "value": "Пользовательская анимация эфира активности музыки" } }, "tr": { @@ -11195,7 +11202,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Свой размер выреза — %.0f" + "value": "Пользовательский размер выреза - %.0f" } }, "tr": { @@ -11394,7 +11401,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Свои визуализаторы (Lottie)" + "value": "Пользовательская визуализация (Lottie)" } }, "tr": { @@ -11574,7 +11581,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Настроить в окне конфигурации" + "value": "Персонализировать в Настройках" } }, "tr": { @@ -11597,6 +11604,10 @@ } } }, + "Customize the appearance of the LocalSend device selection popup that appears when you drop files.": { + "comment": "A description of the LocalSend device picker.", + "isCommentAutoGenerated": true + }, "Dash outline equals the exact notch bounds.": { "comment": "A description under the live preview section of the animation editor.", "isCommentAutoGenerated": true, @@ -11758,7 +11769,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Польз. таймер по умолчанию" + "value": "Стандартный" } } } @@ -11899,6 +11910,7 @@ } } }, + "Denied": {}, "Denied/Revoked": { "comment": "A label describing an extension with denied or revoked permissions.", "isCommentAutoGenerated": true, @@ -12107,7 +12119,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Статус обнаружения" + "value": "Дизайнер" } } } @@ -12130,6 +12142,10 @@ } } }, + "Device Picker Style": { + "comment": "A dropdown menu that lets you choose how the LocalSend device picker is displayed.", + "isCommentAutoGenerated": true + }, "Devices": { "comment": "The title of the \"Devices\" section in the settings menu.", "isCommentAutoGenerated": true, @@ -12464,7 +12480,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Дискретные переходы: Зеленый (0–60%), Желтый (60–85%) и Красный (85–100%)." + "value": "Отключено при активной интеграции BetterDisplay — приложение использует Cmd+Клавиши яркости для своего управления." } } } @@ -12530,7 +12546,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Чтение с диска" + "value": "Диск" } } } @@ -12655,7 +12671,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Показывает HUD-уведомление при подключении Bluetooth-аудиоустройств (наушников, AirPods, колонок), отображая имя и заряд." + "value": "Стиль дисплея" } } } @@ -12737,7 +12753,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Не беспокоить" + "value": "Отображает ближайшее предстоящее событие календаря над или под виджетом погоды. Выбор календаря здесь не зависит от фильтра календаря в динамическом острове." } } } @@ -12779,7 +12795,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Готово" + "value": "Поддержать" } } } @@ -12871,7 +12887,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Загрузить" + "value": "Загрузка" } }, "tr": { @@ -12949,7 +12965,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Стиль иконки загрузки" + "value": "Стиль значка загрузки" } } } @@ -13287,7 +13303,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Бросьте файлы сюда" + "value": "Перетащите файлы сюда" } }, "tr": { @@ -13379,7 +13395,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "dynamic.island" + "value": "Видимость динамического острова" } } }, @@ -13636,7 +13652,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Изменить макет" + "value": "Редактировать макет" } }, "tr": { @@ -13682,6 +13698,7 @@ } } }, + "Empty Slot": {}, "Enable \"Idle Animation\" to customize animations": { "localizations": { "ko": { @@ -13907,6 +13924,7 @@ } } }, + "Enable colored spectograms": {}, "Enable colored spectrograms": { "localizations": { "ar": { @@ -13984,7 +14002,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Цветные спектрограммы" + "value": "Включить цветную спектрограмму" } }, "tr": { @@ -14429,7 +14447,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Заменять системный HUD" + "value": "Включить замену системной панели" } }, "tr": { @@ -14507,7 +14525,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Включить размытие медиа-панели" + "value": "Включите в Настройках → Терминал" } } } @@ -14609,7 +14627,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Включить музыкальную Live Activity" + "value": "Включить эфир активности для музыки" } }, "tr": { @@ -14949,7 +14967,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Включить Live Activity таймера" + "value": "Включить сторонние расширения" } } } @@ -15111,7 +15129,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Улучшите свой опыт с HUD" + "value": "Повысьте удобство работы с панелями" } }, "tr": { @@ -15399,7 +15417,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Список событий" + "value": "Цвет события" } } } @@ -15510,7 +15528,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Выйти" + "value": "Выход" } }, "tr": { @@ -15831,6 +15849,10 @@ } } }, + "Files dropped here will be shared via this service": { + "comment": "A description of what happens when files are dropped into the AirDrop view.", + "isCommentAutoGenerated": true + }, "Files dropped on the shelf will be shared via this service": { "comment": "A description below the currently selected quick share provider, explaining that files dropped on the shelf will be shared via that provider.", "isCommentAutoGenerated": true, @@ -15855,6 +15877,10 @@ } } }, + "Files shared from the shelf will use this service": { + "comment": "A description of how files shared from the shelf will be shared.", + "isCommentAutoGenerated": true + }, "Fill Notch": { "localizations": { "ko": { @@ -16100,7 +16126,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Статус Фокуса" + "value": "Режим обнаружения фокуса" } } } @@ -16177,6 +16203,7 @@ } } }, + "Forward 10s": {}, "Free": { "localizations": { "ru": { @@ -16212,7 +16239,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Своб. · %@" + "value": "Текст" } } } @@ -16245,6 +16272,7 @@ } } }, + "Full Access": {}, "Full charge": { "comment": "Text displayed in the status text when the battery is fully charged.", "isCommentAutoGenerated": true, @@ -16286,6 +16314,7 @@ } } }, + "Full Disk Access unlocks custom Focus icons, colors, and labels. Standard Focus detection still works without it—grant access only if you need personalized indicators.": {}, "Fully charged": { "comment": "Charging fallback label when already charged", "localizations": { @@ -16414,7 +16443,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Основные" + "value": "Общие" } }, "tr": { @@ -16738,7 +16767,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Начать" + "value": "Приступить" } }, "tr": { @@ -17031,7 +17060,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Понятно!" + "value": "Есть!" } }, "tr": { @@ -17105,7 +17134,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Использование GPU" + "value": "ГП" } } } @@ -17274,7 +17303,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Высота:" + "value": "Зелёный" } } } @@ -17339,6 +17368,7 @@ } } }, + "Hide active event and show next upcoming event": {}, "Hide all-day events": { "comment": "A toggle to hide all-day events in the calendar.", "isCommentAutoGenerated": true, @@ -17518,7 +17548,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Скрывать только если приложение NowPlaying в полноэкранном режиме" + "value": "Скрывать только во время полноэкранного режима медиа приложения" } }, "tr": { @@ -17628,7 +17658,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Иерархический" + "value": "Ступенчатый" } }, "tr": { @@ -17815,7 +17845,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Размер истории" + "value": "История" } } } @@ -17838,6 +17868,7 @@ }, "shouldTranslate": false }, + "Hours": {}, "https://example.com/animation.json": { "comment": "A placeholder text for a Lottie JSON URL input field.", "isCommentAutoGenerated": true, @@ -18075,7 +18106,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Стиль HUD" + "value": "Стиль панели" } }, "tr": { @@ -18176,7 +18207,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "HUD" + "value": "Панели" } }, "tr": { @@ -18335,7 +18366,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Импорт" + "value": "Изображение" } } } @@ -18605,7 +18636,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Встроенный" + "value": "Строкой" } }, "tr": { @@ -18690,7 +18721,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Устанавливать обновления автоматически" + "value": "Автоматически устанавливать обновления" } } } @@ -18975,7 +19006,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "HUD подсветки клавиатуры" + "value": "Только что" } } } @@ -19080,6 +19111,7 @@ } } }, + "Last Update": {}, "Last Updated": { "localizations": { "fr": { @@ -19397,11 +19429,15 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Liquid Glass требуется macOS 26 или новее." + "value": "Жидкое стекло" } } } }, + "Liquid Glass Variant": { + "comment": "A label for selecting the variant of the Liquid Glass effect.", + "isCommentAutoGenerated": true + }, "Listens for Focus session changes via distributed notifications": { "localizations": { "ko": { @@ -19673,6 +19709,10 @@ } } }, + "LocalSend Device Picker": { + "comment": "A section header for the LocalSend device picker settings.", + "isCommentAutoGenerated": true + }, "Lock Screen": { "localizations": { "fr": { @@ -19696,7 +19736,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Экран блокировки" + "value": "Загрузка..." } } } @@ -20018,7 +20058,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "URL Lottie JSON" + "value": "Ссылка на JSON Lottie" } }, "tr": { @@ -20278,6 +20318,7 @@ } } }, + "Lyrics": {}, "Mac battery at %d percent": { "comment": "Mac battery gauge accessibility label", "localizations": { @@ -20296,7 +20337,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Заряд батареи Mac: %d%%" + "value": "Режим энергосбережения: %d" } } } @@ -20365,6 +20406,10 @@ } } }, + "Make sure LocalSend is running on other devices": { + "comment": "A description of the error.", + "isCommentAutoGenerated": true + }, "Manage indicator preferences from Settings → Privacy. No manual microphone toggle is required.": { "comment": "A description of how to manage the microphone privacy indicator preferences.", "isCommentAutoGenerated": true, @@ -20384,7 +20429,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Управляйте индикаторами в Настройки → Конфиденциальность. Ручной переключатель микрофона не требуется." + "value": "Стиль главного экрана" } } } @@ -20614,7 +20659,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Соответствовать реальному размеру выреза" + "value": "Соответствовать реальным размерам выреза" } }, "tr": { @@ -20669,7 +20714,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Параметры материала:" + "value": "Материал" } } } @@ -20751,7 +20796,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Макс. емкость: %lld%%" + "value": "Максимальная ёмкость: %lld%%" } }, "tr": { @@ -20983,7 +21028,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Горизонтальные жесты для управления медиа" + "value": "Средний" } }, "tr": { @@ -21183,7 +21228,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Таймаут неактивности медиа" + "value": "Таймаут отсутствия активности медиа" } }, "tr": { @@ -21278,6 +21323,7 @@ } } }, + "Media Panel Width": {}, "Media playback live activity": { "localizations": { "ar": { @@ -21355,7 +21401,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Окно Live Activity для медиа" + "value": "Эфир активности воспроизведения медиа" } }, "tr": { @@ -21555,8 +21601,8 @@ }, "ru": { "stringUnit": { - "state": "translated", - "value": "Mediuм" + "state": "needs_review", + "value": "Medium" } }, "tr": { @@ -21682,7 +21728,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Использование памяти" + "value": "Память" } } } @@ -21764,7 +21810,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Иконка в строке меню" + "value": "Значок в строке меню" } }, "tr": { @@ -21908,7 +21954,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Микрофон %@" + "value": "Микро %@" } }, "tr": { @@ -22211,7 +22257,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Минимальное время наведения" + "value": "Минимальная длительность наведения курсора" } }, "tr": { @@ -22457,7 +22503,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Зеркалить форму" + "value": "Форма зеркала" } }, "tr": { @@ -22779,6 +22825,7 @@ } } }, + "Music": {}, "Music panel variant": { "localizations": { "ko": { @@ -23108,7 +23155,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Имя" + "value": "Название" } }, "tr": { @@ -23310,7 +23357,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Скачивание" + "value": "Сеть" } } } @@ -23453,6 +23500,7 @@ } } }, + "Next Track": {}, "No active interfaces detected yet.": { "comment": "A message displayed when there are no active network interfaces to display.", "isCommentAutoGenerated": true, @@ -23738,7 +23786,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Нет пользовательской анимации" + "value": "Нет пользовательских анимаций" } }, "tr": { @@ -23838,7 +23886,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Нет пользовательского визуализатора" + "value": "Нет пользовательских визуализаций" } }, "tr": { @@ -23861,6 +23909,10 @@ } } }, + "No devices found": { + "comment": "A message displayed when there are no local send devices available.", + "isCommentAutoGenerated": true + }, "No events": { "comment": "A description displayed when there are no events in the calendar.", "isCommentAutoGenerated": true, @@ -23962,7 +24014,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Сегодня событий нет" + "value": "Сегодня нет событий" } }, "tr": { @@ -24003,7 +24055,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Нет исключений" + "value": "Исключений нет" } } } @@ -24412,7 +24464,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Высота дисплея без выреза" + "value": "Высота для дисплеев без выреза" } }, "tr": { @@ -24493,6 +24545,7 @@ } } }, + "Not Determined": {}, "Not Now": { "localizations": { "ar": { @@ -24792,7 +24845,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Высота дисплея с вырезом" + "value": "Высота выреза" } }, "tr": { @@ -25013,7 +25066,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Уведомлять за" + "value": "Ничего не воспроизводится" } } } @@ -25260,7 +25313,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Один или несколько файлов не удалось загрузить" + "value": "Не удалось загрузить один или несколько файлов" } }, "tr": { @@ -25301,7 +25354,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Только иконка приложения" + "value": "Только значок приложения" } } } @@ -25372,7 +25425,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Только иконка скачивания" + "value": "Только значок загрузки" } } } @@ -25455,6 +25508,7 @@ } } }, + "Open Privacy & Security": {}, "Open shelf tab by default if items added": { "localizations": { "ru": { @@ -25520,7 +25574,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Открывает панель ИИ-помощника. По умолчанию Cmd+Shift+A." + "value": "Открывает прозрачное окно предварительного просмотра с имитаций данных, отражающих текущую конфигурацию виджета экрана блокировки." } } } @@ -25646,7 +25700,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Выходные устройства" + "value": "Использование Option в качестве Meta клавиши отправляет Esc+клавишу вместо специальных символов macOS. Отправка сообщений о действиях мыши передаёт события мыши приложениям терминала, например vim или tmux." } } } @@ -25851,7 +25905,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Проценты" + "value": "Процент" } } } @@ -26047,7 +26101,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Закрепите элементы из истории, чтобы сохранить их здесь" + "value": "Остров с закруглёнными углами, похожий на динамический остров iPhone" } } } @@ -26118,6 +26172,7 @@ } } }, + "Play / Pause": {}, "Play feedback when volume is changed": { "localizations": { "ru": { @@ -26604,6 +26659,7 @@ } } }, + "Previous Track": {}, "Privacy Indicators": { "comment": "A heading for the section that lists privacy indicators.", "isCommentAutoGenerated": true, @@ -26700,7 +26756,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Индикатор" + "value": "Индикатор прогресса" } } } @@ -26804,7 +26860,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Стиль индикатора" + "value": "Стиль индикатора прогресса" } }, "tr": { @@ -26910,6 +26966,10 @@ } } }, + "Quick Share settings": { + "comment": "A help text for the \"Quick Share settings\" button in the AirDrop view.", + "isCommentAutoGenerated": true + }, "Quit": { "localizations": { "ar": { @@ -26987,7 +27047,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Завершить" + "value": "Закрыть" } }, "tr": { @@ -27319,6 +27379,7 @@ } } }, + "Recommended minimum width adjusts automatically based on the number of enabled tabs.": {}, "Recording": { "localizations": { "fr": { @@ -27423,6 +27484,11 @@ } } }, + "Refresh devices": {}, + "Rejected": { + "comment": "A label displayed when a device is rejected.", + "isCommentAutoGenerated": true + }, "Release name": { "localizations": { "ar": { @@ -27600,7 +27666,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Запоминать последнюю вкладку" + "value": "Запомнить последнюю вкладку" } }, "tr": { @@ -27686,7 +27752,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Напоминания" + "value": "Виджет напоминаний" } } } @@ -27819,6 +27885,7 @@ } } }, + "Repeat": {}, "Request Access": { "localizations": { "fr": { @@ -27842,11 +27909,12 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Запросить доступ" + "value": "Удалить выбранное" } } } }, + "Request Full Disk Access": {}, "Requires a third-party client with API plugin enabled.": { "comment": "Description of a music controller type when it requires a third-party client with an API plugin enabled.", "isCommentAutoGenerated": true, @@ -28009,6 +28077,8 @@ } } }, + "Reset Media Width": {}, + "Reset Music": {}, "Reset Notch Experiences": { "comment": "A menu item that resets all notch experiences for a particular app.", "isCommentAutoGenerated": true, @@ -28057,6 +28127,8 @@ } } }, + "Reset Timer": {}, + "Reset Timer Width": {}, "Reset to Default": { "localizations": { "fr": { @@ -28109,6 +28181,7 @@ } } }, + "Reset Weather": {}, "Reset Width": { "comment": "A button to reset the expanded notch width to its default value.", "isCommentAutoGenerated": true, @@ -28335,7 +28408,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Восстановить" + "value": "Перезапускает процессы оболочки. Любые несохраненные данные в терминале будут потеряны." } } } @@ -28388,6 +28461,7 @@ } } }, + "Restricted": {}, "Resume": { "localizations": { "fr": { @@ -28460,6 +28534,7 @@ } } }, + "Rewind 10s": {}, "Rich Text": { "comment": "Name for a clipboard item type that represents a rich text format.", "isCommentAutoGenerated": true, @@ -28530,7 +28605,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Вращение:" + "value": "Кольцо" } } } @@ -28613,7 +28688,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Выполняется" + "value": "Запущено" } }, "tr": { @@ -28821,6 +28896,14 @@ } } }, + "Scan Again": { + "comment": "A button to refresh the list of nearby devices.", + "isCommentAutoGenerated": true + }, + "Scanning for devices...": { + "comment": "A description of the scanning process for devices.", + "isCommentAutoGenerated": true + }, "Screen Assistant": { "localizations": { "ko": { @@ -29082,7 +29165,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Искать в буфере обмена..." + "value": "Строк обратной прокрутки" } } } @@ -29245,7 +29328,6 @@ } }, "Seconds": { - "extractionState": "stale", "localizations": { "fr": { "stringUnit": { @@ -29369,6 +29451,10 @@ } } }, + "Select Device": { + "comment": "A label displayed above the LocalSend device picker view.", + "isCommentAutoGenerated": true + }, "Select one or more profiles to customize your experience": { "comment": "A description below the header that instructs users to select one or more profiles to customize their experience.", "isCommentAutoGenerated": true, @@ -29813,7 +29899,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Сеанс · ↓ %1$@ · ↑ %2$@" + "value": "Сессия · ↓ %1$@ · ↑ %2$@" } } } @@ -30077,7 +30163,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Полка" + "value": "Панель файлов" } }, "tr": { @@ -30234,6 +30320,7 @@ } } }, + "Show \"Change Media Output\" control": {}, "Show All Color Formats": { "localizations": { "ru": { @@ -30331,7 +30418,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Показывать индикатор батареи" + "value": "Показать индикатор заряда батареи" } }, "tr": { @@ -30458,7 +30545,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Показывать индикатор зарядки" + "value": "Показать индикатор зарядки" } } } @@ -30631,7 +30718,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Показывать забавную анимацию при неактивности" + "value": "Показать прикольную анимацию лица при бездействия" } }, "tr": { @@ -30706,7 +30793,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Показывать прогресс загрузки" + "value": "Показать прогресс загрузки" } } } @@ -30787,6 +30874,7 @@ } } }, + "Show floating media controls": {}, "Show floating pause/stop controls": { "localizations": { "ko": { @@ -30804,7 +30892,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Показывать плавающие элементы паузы/остановки" + "value": "Показать вкладки расширений" } } } @@ -30953,6 +31041,7 @@ } } }, + "Show merged AirPlay and output devices": {}, "Show next calendar event": { "localizations": { "ru": { @@ -31040,7 +31129,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Показывать на конкретном дисплее" + "value": "Показать на определённом дисплее" } }, "tr": { @@ -31140,7 +31229,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Показывать на всех дисплеях" + "value": "Показать на всех дисплеях" } }, "tr": { @@ -31359,7 +31448,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Показывать кнопки перемешивания/повтора" + "value": "Показать кнопки перемешивания и повтора" } }, "tr": { @@ -31551,7 +31640,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Показывает рассуждения модели перед выдачей ответа" + "value": "Показывает экранный помощник во всплывающей панели рядом с вырезом" } } } @@ -31578,6 +31667,8 @@ } } }, + "Shrinks the lock screen media panel while keeping the expanded view full width.": {}, + "Shuffle": {}, "Simple and minimal interface with just the essentials for everyday tasks.": { "localizations": { "ko": { @@ -31612,7 +31703,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Размер и масштаб" + "value": "Простой и минималистичный интерфейс с самым необходимым для повседневных задач." } } } @@ -32032,7 +32123,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Стиль быстрого просмотра" + "value": "Стиль предварительного просмотра" } }, "tr": { @@ -32548,7 +32639,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Начать" + "value": "Стандартные элементы управления предыдущим/следующим треком" } } } @@ -33082,7 +33173,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Остановить" + "value": "Держите дела под контролем с календарём, таймером и мониторингом батареи." } } } @@ -33405,7 +33496,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Поддержите нас" + "value": "Поддержать нас" } }, "tr": { @@ -33725,7 +33816,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Сделать снимок выделенной области" + "value": "Вкладка" } } } @@ -33889,7 +33980,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Эти элементы находятся рядом с вырезом во время работы таймера." + "value": "Эти элементы управления дублируют вкладку «Экран блокировки», позволяя настроить панель медиа с акцентом на параметры воспроизведения." } } } @@ -34487,6 +34578,7 @@ } } }, + "Timer Widget Width": {}, "Title": { "comment": "A label for the title input field in the note editor.", "isCommentAutoGenerated": true, @@ -34588,7 +34680,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Открыть/закрыть вырез:" + "value": "Переключить открытие выреза:" } }, "tr": { @@ -34688,7 +34780,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Быстрый просмотр:" + "value": "Переключить предварительный просмотр:" } }, "tr": { @@ -35055,7 +35147,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Смахивание двумя пальцами вверх для закрытия, вниз для открытия, если отключено открытие при наведении." + "value": "Сделайте свайп двумя пальцами вверх, чтобы свернуть вырез и вниз - чтобы его развернуть, когда функция **Развернуть вырез при наведении курсора\" отключена" } }, "tr": { @@ -35302,7 +35394,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Разблокируйте расширенные функции и улучшите свой опыт. Обновитесь для дополнительных настроек!" + "value": "Откройте для себя расширенные возможности и улучшите свой пользовательский опыт. Обновите подписку прямо сейчас, чтобы получить больше настроек!" } }, "tr": { @@ -35402,7 +35494,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "со звуком" + "value": "незаглушенный" } }, "tr": { @@ -35470,7 +35562,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Интервал обновления" + "value": "Отключено" } } } @@ -35581,7 +35673,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Перейти на Pro" + "value": "Обновиться до Pro" } }, "tr": { @@ -35692,7 +35784,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Использование" + "value": "Ссылка" } } } @@ -36006,7 +36098,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Используется" + "value": "Использовать без инструментов разработчика (DevTools)" } } } @@ -36156,6 +36248,10 @@ }, "shouldTranslate": false }, + "Variant %lld": { + "comment": "A label for a specific variant of the Liquid Glass.", + "isCommentAutoGenerated": true + }, "Version": { "localizations": { "ar": { @@ -36356,6 +36452,7 @@ } } }, + "Vertical Bar": {}, "Vertical offset": { "localizations": { "ko": { @@ -36390,7 +36487,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "По вертикали:" + "value": "Вертикальный отступ" } } }, @@ -36483,7 +36580,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Смотреть на GitHub: th-ch/youtube-music" + "value": "Посмотреть на GitHub: th-ch/youtube-music" } }, "tr": { @@ -36629,7 +36726,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Погода" + "value": "З " } } } @@ -36971,7 +37068,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "При отключении все сочетания клавиш будут неактивны." + "value": "Когда параметр «Жирный текст как яркие цвета» отключён, он отображается более толстым начертанием шрифта вместо использования ярких ANSI-цветов." } } } @@ -37073,7 +37170,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "При включении на вкладке Stats будут отображаться графики производительности в реальном времени." + "value": "При включении, вырез на экране сдвигается вверх и скрывается на внешних (не имеющих выреза) дисплеях, пока вы не наведете на него курсор." } } } @@ -37118,6 +37215,7 @@ } } }, + "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value.": {}, "Width:": { "extractionState": "stale", "localizations": { @@ -37244,11 +37342,12 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Запись" + "value": "Работает со всеми медиа приложениями, включая браузеры, определяя, что воспроизводится. Заметка: функция может быть удалена в будущих версиях macOS." } } } }, + "Write Only": {}, "Written": { "localizations": { "ru": { @@ -37364,7 +37463,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Теперь вы можете наслаждаться приложением. Настройки всегда доступны в меню." + "value": "Теперь Вы можете пользоваться приложением. Если хотите внести дополнительные изменения, Вы всегда можете сделать это в настройках." } }, "tr": { @@ -37464,7 +37563,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "Все готово!" + "value": "Всё готово!" } }, "tr": { @@ -37638,7 +37737,7 @@ "ru": { "stringUnit": { "state": "translated", - "value": "YouTube Music требует установки этого стороннего приложения: " + "value": "Для работы YouTube Music необходимо установить следующее стороннее приложение: " } }, "tr": { diff --git a/DynamicIsland/Providers/CalendarServiceProviding.swift b/DynamicIsland/Providers/CalendarServiceProviding.swift index 055de25..280b90d 100644 --- a/DynamicIsland/Providers/CalendarServiceProviding.swift +++ b/DynamicIsland/Providers/CalendarServiceProviding.swift @@ -272,7 +272,7 @@ extension EKCalendar { var accountTitle: String { switch source.sourceType { case .local, .subscribed, .birthdays: - return "Other" + return String(localized: "Other") default: return source.title } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 624d355..8e5aa18 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -793,7 +793,8 @@ struct SettingsView: View { SettingsSearchEntry(tab: .shelf, title: "Expanded drag detection area", keywords: ["shelf", "drag"], highlightID: SettingsTab.shelf.highlightID(for: "Expanded drag detection area")), SettingsSearchEntry(tab: .shelf, title: "Copy items on drag", keywords: ["shelf", "drag", "copy"], highlightID: SettingsTab.shelf.highlightID(for: "Copy items on drag")), SettingsSearchEntry(tab: .shelf, title: "Remove from shelf after dragging", keywords: ["shelf", "drag", "remove"], highlightID: SettingsTab.shelf.highlightID(for: "Remove from shelf after dragging")), - SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")), + SettingsSearchEntry(tab: .shelf, title: "Quick Share Service", keywords: ["shelf", "share", "airdrop", "localsend"], highlightID: SettingsTab.shelf.highlightID(for: "Quick Share Service")), + SettingsSearchEntry(tab: .shelf, title: "LocalSend Device Picker Style", keywords: ["localsend", "glass", "picker", "material"], highlightID: SettingsTab.shelf.highlightID(for: "Device Picker Style")), // Appearance SettingsSearchEntry(tab: .appearance, title: "Main screen style", keywords: ["dynamic island", "pill", "non-notch", "display style", "notch style"], highlightID: SettingsTab.appearance.highlightID(for: "Main screen style")), @@ -1609,7 +1610,7 @@ private struct HUDAndOSDSettingsView: View { VStack(spacing: 20) { HStack(spacing: 16) { HUDSelectionCard( - title: "Dynamic Island", + title: String(localized: "Dynamic Island"), isSelected: selectedTab == .hud, action: { selectedTab = .hud @@ -1649,7 +1650,7 @@ private struct HUDAndOSDSettingsView: View { } HUDSelectionCard( - title: "Custom OSD", + title: String(localized: "Custom OSD"), isSelected: selectedTab == .osd, action: { selectedTab = .osd @@ -1691,7 +1692,7 @@ private struct HUDAndOSDSettingsView: View { } HUDSelectionCard( - title: "Vertical Bar", + title: String(localized: "Vertical Bar"), isSelected: selectedTab == .vertical, action: { selectedTab = .vertical @@ -1734,7 +1735,7 @@ private struct HUDAndOSDSettingsView: View { } HUDSelectionCard( - title: "Circular", + title: String(localized: "Circular"), isSelected: selectedTab == .circular, action: { selectedTab = .circular @@ -2527,9 +2528,11 @@ struct Media: View { } } if showShuffleAndRepeat { - Defaults.Toggle("Show \"Change Media Output\" control", key: .showMediaOutputControl) - .settingsHighlight(id: highlightID("Show Change Media Output control")) - .help("Adds the AirPlay/route picker button back to the customizable controls palette.") + Defaults.Toggle(key: .showMediaOutputControl) { + Text("Show \"Change Media Output\" control") + } + .settingsHighlight(id: highlightID("Show Change Media Output control")) + .help("Adds the AirPlay/route picker button back to the customizable controls palette.") MusicSlotConfigurationView() } else { Text("Turn on customizable controls to rearrange media buttons.") @@ -2574,10 +2577,9 @@ struct Media: View { ) .disabled(standardControlsSuppressed) .help(standardControlsSuppressed ? "Standard notch media controls are hidden while this toggle is off." : "") - Defaults.Toggle( - "Show floating media controls", - key: .musicControlWindowEnabled - ) + Defaults.Toggle(key: .musicControlWindowEnabled) { + Text("Show floating media controls") + } .disabled(!coordinator.musicLiveActivityEnabled || standardControlsSuppressed) .help("Displays play/pause and skip buttons beside the notch while music is active. Disabled by default.") Toggle("Enable sneak peek", isOn: $enableSneakPeek) @@ -2622,9 +2624,11 @@ struct Media: View { } .disabled(!enableLockScreenMediaWidget) if isAppleMusicActive { - Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput) - .disabled(!enableLockScreenMediaWidget) - .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) + Defaults.Toggle(key: .lockScreenMusicMergedAirPlayOutput) { + Text("Show merged AirPlay and output devices") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) } Defaults.Toggle(key: .lockScreenPanelShowsBorder) { Text("Show panel border") @@ -2974,10 +2978,9 @@ struct CalendarSettings: View { } } - Defaults.Toggle( - "Hide active event and show next upcoming event", - key: .lockScreenShowCalendarEventAfterStartEnabled - ) + Defaults.Toggle(key: .lockScreenShowCalendarEventAfterStartEnabled) { + Text("Hide active event and show next upcoming event") + } .disabled(!lockScreenShowCalendarEvent || lockScreenShowCalendarEventEntireDuration) .settingsHighlight(id: highlightID("Hide active event and show next upcoming event")) @@ -3076,12 +3079,12 @@ struct CalendarSettings: View { private func statusText(for status: EKAuthorizationStatus) -> String { switch status { - case .fullAccess, .authorized: return "Full Access" - case .writeOnly: return "Write Only" - case .denied: return "Denied" - case .restricted: return "Restricted" - case .notDetermined: return "Not Determined" - @unknown default: return "Unknown" + case .fullAccess, .authorized: return String(localized: "Full Access") + case .writeOnly: return String(localized: "Write Only") + case .denied: return String(localized: "Denied") + case .restricted: return String(localized: "Restricted") + case .notDetermined: return String(localized: "Not Determined") + @unknown default: return String(localized: "Unknown") } } @@ -3438,12 +3441,14 @@ struct Shelf: View { if let imgData = provider.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) } else { Image(systemName: "square.and.arrow.up") + .frame(width: 16, height: 16) } } - .frame(width: 16, height: 16) .foregroundColor(.accentColor) Text(provider.id) } @@ -3459,12 +3464,14 @@ struct Shelf: View { if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFit() + .frame(width: 16, height: 16) + .clipShape(RoundedRectangle(cornerRadius: 3)) } else { Image(systemName: "square.and.arrow.up") + .frame(width: 16, height: 16) } } - .frame(width: 16, height: 16) .foregroundColor(.accentColor) VStack(alignment: .leading, spacing: 2) { Text("Currently selected: \(selectedProvider.id)") @@ -3486,6 +3493,10 @@ struct Shelf: View { .font(.caption) .foregroundStyle(.secondary) } + + if quickShareProvider == "LocalSend" { + LocalSendSettingsSection(highlightID: highlightID) + } } .accentColor(.effectiveAccent) .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 { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared @@ -3553,12 +3599,12 @@ struct LiveActivitiesSettings: View { Section { if !fullDiskAccessPermission.isAuthorized { SettingsPermissionCallout( - title: "Custom Focus metadata", - message: "Full Disk Access unlocks custom Focus icons, colors, and labels. Standard Focus detection still works without it—grant access only if you need personalized indicators.", + title: String(localized: "Custom Focus metadata"), + message: String(localized: "Full Disk Access unlocks custom Focus icons, colors, and labels. Standard Focus detection still works without it—grant access only if you need personalized indicators."), icon: "externaldrive.fill", iconColor: .purple, - requestButtonTitle: "Request Full Disk Access", - openSettingsButtonTitle: "Open Privacy & Security", + requestButtonTitle: String(localized: "Request Full Disk Access"), + openSettingsButtonTitle: String(localized: "Open Privacy & Security"), requestAction: { fullDiskAccessPermission.requestAccessPrompt() }, openSettingsAction: { fullDiskAccessPermission.openSystemSettings() } ) @@ -3938,8 +3984,9 @@ struct Appearance: View { Text("Enable colored spectrograms") } .settingsHighlight(id: highlightID("Enable colored spectrograms")) - Defaults - .Toggle("Player tinting", key: .playerColorTinting) + Defaults.Toggle(key: .playerColorTinting) { + Text("Enable colored spectograms") + } Defaults.Toggle(key: .lightingEffect) { Text("Enable blur effect behind album art") } @@ -4403,8 +4450,8 @@ struct Appearance: View { } let description = enableMinimalisticUI - ? "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value." - : "Recommended minimum width adjusts automatically based on the number of enabled tabs." + ? String(localized: "Width adjustments apply only to the standard notch layout. Disable Minimalistic UI to edit this value.") + : String(localized: "Recommended minimum width adjusts automatically based on the number of enabled tabs.") Text(description) .font(.caption) @@ -4638,9 +4685,11 @@ struct LockScreenSettings: View { .disabled(!enableLockScreenMediaWidget) .settingsHighlight(id: highlightID("Show media app icon")) if isAppleMusicActive { - Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput) - .disabled(!enableLockScreenMediaWidget) - .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) + Defaults.Toggle(key: .lockScreenMusicMergedAirPlayOutput) { + Text("Show merged AirPlay and output devices") + } + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) } Defaults.Toggle(key: .lockScreenPanelShowsBorder) { Text("Show panel border") @@ -5248,9 +5297,9 @@ private struct LockScreenPositioningControls: View { HStack(alignment: .top, spacing: 24) { offsetColumn( - title: "Weather", + title: String(localized: "Weather"), value: weatherOffset, - resetTitle: "Reset Weather", + resetTitle: String(localized: "Reset Weather"), resetAction: resetWeatherOffset ) @@ -5258,9 +5307,9 @@ private struct LockScreenPositioningControls: View { .frame(height: 64) offsetColumn( - title: "Timer", + title: String(localized: "Timer"), value: timerOffset, - resetTitle: "Reset Timer", + resetTitle: String(localized: "Reset Timer"), resetAction: resetTimerOffset ) @@ -5268,9 +5317,9 @@ private struct LockScreenPositioningControls: View { .frame(height: 64) offsetColumn( - title: "Music", + title: String(localized: "Music"), value: musicOffset, - resetTitle: "Reset Music", + resetTitle: String(localized: "Reset Music"), resetAction: resetMusicOffset ) @@ -5282,21 +5331,21 @@ private struct LockScreenPositioningControls: View { VStack(alignment: .leading, spacing: 16) { widthSlider( - title: "Media Panel Width", + title: String(localized: "Media Panel Width"), value: musicWidthBinding, range: musicWidthRange, - resetTitle: "Reset Media Width", + resetTitle: String(localized: "Reset Media Width"), resetAction: resetMusicWidth, - helpText: "Shrinks the lock screen media panel while keeping the expanded view full width." + helpText: String(localized: "Shrinks the lock screen media panel while keeping the expanded view full width.") ) widthSlider( - title: "Timer Widget Width", + title: String(localized: "Timer Widget Width"), value: timerWidthBinding, range: timerWidthRange, - resetTitle: "Reset Timer Width", + resetTitle: String(localized: "Reset Timer Width"), resetAction: resetTimerWidth, - helpText: "Adjusts the lock screen timer widget width without affecting button sizing." + helpText: String(localized: "Adjusts the lock screen timer widget width without affecting button sizing.") ) } } header: { @@ -6136,9 +6185,9 @@ struct TimerSettings: View { Text("Default Custom Timer") .font(.headline) - TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23) - TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59) - TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59) + TimerDurationStepperRow(title: String(localized: "Hours"), value: $customHours, range: 0...23) + TimerDurationStepperRow(title: String(localized: "Minutes"), value: $customMinutes, range: 0...59) + TimerDurationStepperRow(title: String(localized: "Seconds"), value: $customSeconds, range: 0...59) HStack { Text("Current default:") @@ -6478,9 +6527,9 @@ private struct TimerPresetEditorRow: View { } HStack(spacing: 16) { - TimerPresetComponentControl(title: "Hours", value: hoursBinding, range: 0...23) - TimerPresetComponentControl(title: "Minutes", value: minutesBinding, range: 0...59) - TimerPresetComponentControl(title: "Seconds", value: secondsBinding, range: 0...59) + TimerPresetComponentControl(title: String(localized: "Hours"), value: hoursBinding, range: 0...23) + TimerPresetComponentControl(title: String(localized: "Minutes"), value: minutesBinding, range: 0...59) + TimerPresetComponentControl(title: String(localized: "Seconds"), value: secondsBinding, range: 0...59) } ColorPicker("Accent colour", selection: colorBinding, supportsOpacity: false) diff --git a/DynamicIsland/components/Shelf/AirDropView.swift b/DynamicIsland/components/Shelf/AirDropView.swift index cc37037..a65bc3a 100644 --- a/DynamicIsland/components/Shelf/AirDropView.swift +++ b/DynamicIsland/components/Shelf/AirDropView.swift @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import AppKit +import Defaults import SwiftUI import UniformTypeIdentifiers @@ -24,6 +26,10 @@ struct AirDropView: View { @State var trigger: UUID = .init() @State var targeting = false + @Default(.quickShareProvider) var quickShareProvider + @StateObject private var quickShareService = QuickShareService.shared + @State private var showShareSettings = false + @State private var isSwitchHover = false var body: some View { dropArea @@ -40,7 +46,103 @@ struct AirDropView: View { .fill(.white.opacity(0.1)) .opacity(0.5) .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) .contentShape(Rectangle()) } diff --git a/DynamicIsland/components/Shelf/Services/LocalSendService.swift b/DynamicIsland/components/Shelf/Services/LocalSendService.swift new file mode 100644 index 0000000..e1d5d42 --- /dev/null +++ b/DynamicIsland/components/Shelf/Services/LocalSendService.swift @@ -0,0 +1,1117 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ + +import Foundation +import Network +import Defaults +import UniformTypeIdentifiers +import Darwin + +struct LocalSendDeviceInfo: Identifiable, Hashable, Sendable { + let id: String + let alias: String + let ip: String + let port: Int + let https: Bool + let model: String? + + var displayName: String { + if let model, !model.isEmpty { + return "\(alias) (\(model))" + } + return alias + } + + var baseURL: String { + let scheme = https ? "https" : "http" + return "\(scheme)://\(ip):\(port)" + } +} + +enum LocalSendTransferState: Equatable { + case idle + case sending + case completed + case failed(String) + case rejected(deviceID: String) +} + +@MainActor +final class LocalSendService: NSObject, ObservableObject { + static let shared = LocalSendService() + + @Published private(set) var devices: [LocalSendDeviceInfo] = [] + @Published private(set) var isRefreshing = false + @Published private(set) var isSending = false + @Published private(set) var sendProgress: Double = 0 + @Published private(set) var transferState: LocalSendTransferState = .idle + @Published private(set) var rejectedDeviceIDs: Set = [] + @Published var selectedDeviceID: String { + didSet { Defaults[.localSendSelectedDeviceID] = selectedDeviceID } + } + + private let multicastGroupHost = "224.0.0.167" + private let defaultPort = 53317 + private var connectionGroup: NWConnectionGroup? + private var registerListener: NWListener? + private var cleanupTask: Task? + private var announceTask: Task? + private var activeRefreshTask: Task? + private var refreshSessionID = UUID() + private var discoveredByID: [String: (device: LocalSendDeviceInfo, lastSeen: Date)] = [:] + private var recentProbeIPs: [String] = [] + private var knownPeerIPs: [String] = [] + private var isStarted = false + private var completionDismissTask: Task? + + private override init() { + selectedDeviceID = Defaults[.localSendSelectedDeviceID] + super.init() + } + + func clearRejectedStatus(for deviceID: String) { + rejectedDeviceIDs.remove(deviceID) + } + + func clearAllRejectedStatuses() { + rejectedDeviceIDs.removeAll() + } + + func startDiscovery() { + guard !isStarted else { return } + isStarted = true + + startRegisterListenerIfNeeded() + + do { + let group = try NWMulticastGroup(for: [ + .hostPort( + host: .init(multicastGroupHost), + port: .init(integerLiteral: NWEndpoint.Port.IntegerLiteralType(defaultPort)) + ), + ]) + let params = NWParameters.udp + params.allowLocalEndpointReuse = true + params.includePeerToPeer = true + + let connectionGroup = NWConnectionGroup(with: group, using: params) + connectionGroup.setReceiveHandler(maximumMessageSize: 65_536) { [weak self] message, content, _ in + guard let content, let self else { return } + Task { @MainActor in + self.handleIncoming(content: content, endpoint: message.remoteEndpoint) + } + } + + connectionGroup.stateUpdateHandler = { _ in } + connectionGroup.start(queue: .global(qos: .utility)) + self.connectionGroup = connectionGroup + + sendAnnouncement() + + cleanupTask = Task { [weak self] in + while let self, !Task.isCancelled { + try? await Task.sleep(nanoseconds: 15_000_000_000) + self.cleanupStale() + } + } + + announceTask = Task { [weak self] in + while let self, !Task.isCancelled { + try? await Task.sleep(nanoseconds: 8_000_000_000) + self.sendAnnouncement() + } + } + } catch { + Logger.log("LocalSend discovery start failed: \(error.localizedDescription)", category: .extensions) + } + } + + func refreshDeviceScan() { + activeRefreshTask?.cancel() + startDiscovery() + + let sessionID = UUID() + refreshSessionID = sessionID + isRefreshing = true + + // Keep listener state and run a LocalSend-like refresh sequence: + // multicast announce burst first, then targeted/fallback HTTP discovery. + activeRefreshTask = Task { [weak self] in + guard let self, !Task.isCancelled else { return } + + let startedAt = Date() + defer { + if self.refreshSessionID == sessionID { + self.isRefreshing = false + self.activeRefreshTask = nil + } + } + + let burstDelays: [UInt64] = [100_000_000, 500_000_000, 2_000_000_000] + for delay in burstDelays { + try? await Task.sleep(nanoseconds: delay) + guard !Task.isCancelled else { return } + self.sendAnnouncement() + } + + guard !Task.isCancelled else { return } + await self.probeNearbyDevicesDirectly(limit: 20, timeout: 0.14) + + guard !Task.isCancelled else { return } + if !self.hasFreshDiscovery(since: startedAt), + let localIP = self.ownIPv4Address() { + await self.probeLocalSubnetLegacy(localIP: localIP, timeout: 0.12) + } + + guard !Task.isCancelled else { return } + self.pruneUnavailableDevices(since: startedAt) + } + } + + private func pruneUnavailableDevices(since date: Date) { + let previousCount = discoveredByID.count + discoveredByID = discoveredByID.filter { $0.value.lastSeen >= date } + if discoveredByID.count != previousCount { + refreshDevices() + } + } + + private func probeNearbyDevicesDirectly(limit: Int = 12, timeout: TimeInterval = 0.14) async { + let port = defaultPort + let candidates = candidateIPsForActiveProbe(limit: max(1, limit)) + guard !candidates.isEmpty else { return } + let deadline = Date().addingTimeInterval(1.0) + + await withTaskGroup(of: LocalSendDeviceInfo?.self) { group in + let maxConcurrent = 4 + var iterator = candidates.makeIterator() + + for _ in 0 ..< min(maxConcurrent, candidates.count) { + guard let ip = iterator.next() else { break } + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: 0.14) + } + } + + while let result = await group.next() { + if Date() > deadline { + group.cancelAll() + return + } + + guard !Task.isCancelled else { + group.cancelAll() + return + } + + if let device = result { + discoveredByID[device.id] = (device, Date()) + rememberKnownPeerIP(device.ip) + rememberRecentProbeIP(device.ip) + } + + if let ip = iterator.next() { + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + } + } + + refreshDevices() + } + + private func candidateIPsForActiveProbe(limit: Int) -> [String] { + var ordered: [String] = [] + var seen = Set() + + if let selected = devices.first(where: { $0.id == selectedDeviceID })?.ip, + seen.insert(selected).inserted { + ordered.append(selected) + } + + for ip in recentProbeIPs where seen.insert(ip).inserted { + ordered.append(ip) + } + + for ip in discoveredByID.values.map(\.device.ip) where seen.insert(ip).inserted { + ordered.append(ip) + } + + for ip in knownPeerIPs where seen.insert(ip).inserted { + ordered.append(ip) + } + + if ordered.count > limit { + return Array(ordered.prefix(limit)) + } + return ordered + } + + private func probeLocalSubnetLegacy(localIP: String, timeout: TimeInterval) async { + guard isValidIPv4(localIP) else { return } + let parts = localIP.split(separator: ".") + guard parts.count == 4 else { return } + + let prefix = parts.prefix(3).joined(separator: ".") + let host = Int(parts[3]) ?? 0 + let candidates = (1 ... 254) + .filter { $0 != host } + .map { "\(prefix).\($0)" } + + await probeExactIPs(candidates, timeout: timeout, concurrency: 50) + } + + private func probeExactIPs(_ ips: [String], timeout: TimeInterval, concurrency: Int) async { + let port = defaultPort + let unique = Array(NSOrderedSet(array: ips).compactMap { $0 as? String }) + guard !unique.isEmpty else { return } + + await withTaskGroup(of: LocalSendDeviceInfo?.self) { group in + var iterator = unique.makeIterator() + + for _ in 0 ..< min(max(1, concurrency), unique.count) { + guard let ip = iterator.next() else { break } + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + + while let result = await group.next() { + guard !Task.isCancelled else { + group.cancelAll() + return + } + + if let device = result { + discoveredByID[device.id] = (device, Date()) + rememberKnownPeerIP(device.ip) + rememberRecentProbeIP(device.ip) + } + + if let ip = iterator.next() { + group.addTask { + await Self.probeDeviceInfo(at: ip, port: port, timeout: timeout) + } + } + } + } + + refreshDevices() + } + + private func hasFreshDiscovery(since date: Date) -> Bool { + discoveredByID.values.contains { $0.lastSeen >= date } + } + + private nonisolated static func probeDeviceInfo(at ip: String, port: Int, timeout: TimeInterval) async -> LocalSendDeviceInfo? { + let schemes = ["http", "https"] + let paths = ["/api/localsend/v2/info"] + + for scheme in schemes { + for path in paths { + guard var components = URLComponents(string: "\(scheme)://\(ip):\(port)\(path)") else { continue } + components.queryItems = [ + URLQueryItem(name: "fingerprint", value: "atoll.localsend.bridge"), + ] + guard let url = components.url else { continue } + + var request = URLRequest(url: url) + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + + do { + let (data, response) = try await probeSession.data(for: request) + guard let http = response as? HTTPURLResponse, + (200 ... 299).contains(http.statusCode), + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String, + fingerprint != "atoll.localsend.bridge" + else { + continue + } + + let model = json["deviceModel"] as? String + let usesHTTPS = (json["protocol"] as? String) == "https" || scheme == "https" + + return LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: ip, + port: port, + https: usesHTTPS, + model: model + ) + } catch { + continue + } + } + } + + return nil + } + + private nonisolated static let probeSession: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 0.2 + config.timeoutIntervalForResource = 0.2 + config.waitsForConnectivity = false + return URLSession(configuration: config, delegate: LocalSendTLSDelegate(), delegateQueue: nil) + }() + + private func rememberRecentProbeIP(_ ip: String) { + guard isValidIPv4(ip) else { return } + recentProbeIPs.removeAll { $0 == ip } + recentProbeIPs.insert(ip, at: 0) + if recentProbeIPs.count > 12 { + recentProbeIPs = Array(recentProbeIPs.prefix(12)) + } + } + + private func rememberKnownPeerIP(_ ip: String) { + guard isValidIPv4(ip) else { return } + knownPeerIPs.removeAll { $0 == ip } + knownPeerIPs.insert(ip, at: 0) + if knownPeerIPs.count > 48 { + knownPeerIPs = Array(knownPeerIPs.prefix(48)) + } + } + + private func arpNeighborIPs() -> [String] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/arp") + process.arguments = ["-an"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return [] } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let output = String(data: data, encoding: .utf8) else { return [] } + + let regex = try NSRegularExpression(pattern: #"\((\d{1,3}(?:\.\d{1,3}){3})\)"#) + let nsrange = NSRange(output.startIndex..() + for match in matches { + guard match.numberOfRanges > 1, + let range = Range(match.range(at: 1), in: output) + else { continue } + let ip = String(output[range]) + guard isValidIPv4(ip) else { continue } + if ip.hasPrefix("169.254.") { continue } + if ip == "255.255.255.255" { continue } + + if let ownPrefix { + let ipPrefix = ip.split(separator: ".").prefix(3).joined(separator: ".") + guard ipPrefix == ownPrefix else { continue } + } + + if seen.insert(ip).inserted { + ips.append(ip) + } + } + + return ips + } catch { + return [] + } + } + + private func localSubnetSweepIPs(radius: Int) -> [String] { + guard let own = ownIPv4Address(), isValidIPv4(own) else { return [] } + let parts = own.split(separator: ".").compactMap { Int($0) } + guard parts.count == 4 else { return [] } + + let prefix = "\(parts[0]).\(parts[1]).\(parts[2])" + let myHost = parts[3] + let start = max(1, myHost - radius) + let end = min(254, myHost + radius) + + return (start ... end) + .filter { $0 != myHost } + .map { "\(prefix).\($0)" } + } + + private func ownIPv4Address() -> String? { + var ifaddr: UnsafeMutablePointer? + guard getifaddrs(&ifaddr) == 0, let first = ifaddr else { return nil } + defer { freeifaddrs(ifaddr) } + + var ptr: UnsafeMutablePointer? = first + while let current = ptr { + defer { ptr = current.pointee.ifa_next } + + let family = current.pointee.ifa_addr.pointee.sa_family + let flags = Int32(current.pointee.ifa_flags) + guard family == UInt8(AF_INET), + (flags & IFF_UP) == IFF_UP, + (flags & IFF_LOOPBACK) == 0 + else { continue } + + var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + var addr = current.pointee.ifa_addr.pointee + let result = getnameinfo( + &addr, + socklen_t(current.pointee.ifa_addr.pointee.sa_len), + &hostBuffer, + socklen_t(hostBuffer.count), + nil, + 0, + NI_NUMERICHOST + ) + guard result == 0 else { continue } + + let ip = String(cString: hostBuffer) + if isValidIPv4(ip) { + return ip + } + } + + return nil + } + + private func isValidIPv4(_ ip: String) -> Bool { + let parts = ip.split(separator: ".") + guard parts.count == 4 else { return false } + for p in parts { + guard let v = Int(p), (0 ... 255).contains(v) else { return false } + } + return true + } + + func send(items: [Any]) async throws { + let startedAt = Date() + isSending = true + sendProgress = 0 + transferState = .sending + completionDismissTask?.cancel() + + do { + guard let target = devices.first(where: { $0.id == selectedDeviceID }) ?? devices.first else { + throw LocalSendServiceError.noDeviceSelected + } + + let files = try await buildTransferFiles(from: items) + guard !files.isEmpty else { throw LocalSendServiceError.noTransferableItems } + + let prepare = try await prepareUpload(files: files, to: target) + guard !prepare.fileTokens.isEmpty else { + sendProgress = 1 + await finishSending(startedAt: startedAt, success: true) + return + } + + let uploads: [(TransferFile, String)] = files.compactMap { file in + guard let token = prepare.fileTokens[file.id] else { return nil } + return (file, token) + } + + // Compute total bytes to provide smooth overall progress + let totalBytes = uploads.reduce(Int64(0)) { acc, entry in acc + Int64(entry.0.data.count) } + var bytesCompleted: Int64 = 0 + + for (index, entry) in uploads.enumerated() { + let file = entry.0 + let fileSize = Int64(file.data.count) + + try await upload(file: file, sessionID: prepare.sessionID, token: entry.1, to: target) { fileFraction in + if totalBytes > 0 { + let currentSent = Int64(Double(fileSize) * fileFraction) + let overall = Double(bytesCompleted + currentSent) / Double(totalBytes) + Task { @MainActor in self.sendProgress = overall } + } else { + // Fallback to file-index-based progress when sizes unknown + Task { @MainActor in self.sendProgress = Double(index) / Double(max(uploads.count, 1)) } + } + } + + bytesCompleted += fileSize + } + + sendProgress = 1 + await finishSending(startedAt: startedAt, success: true) + } catch let error as LocalSendServiceError { + if case .transferRejected = error { + rejectedDeviceIDs.insert(selectedDeviceID) + transferState = .rejected(deviceID: selectedDeviceID) + } else { + transferState = .failed(error.localizedDescription) + } + await finishSending(startedAt: startedAt, success: false) + throw error + } catch { + transferState = .failed(error.localizedDescription) + await finishSending(startedAt: startedAt, success: false) + throw error + } + } + + private func finishSending(startedAt: Date, success: Bool) async { + let elapsed = Date().timeIntervalSince(startedAt) + let minimumVisibleDuration: TimeInterval = 0.8 + if elapsed < minimumVisibleDuration { + let remaining = minimumVisibleDuration - elapsed + try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) + } + isSending = false + sendProgress = 0 + + if success { + transferState = .completed + // Auto-dismiss completed state after 3 seconds + completionDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 3_000_000_000) + if case .completed = self.transferState { + self.transferState = .idle + } + } + } else { + // Auto-dismiss failed/rejected state after 4 seconds + completionDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 4_000_000_000) + if case .failed = self.transferState { + self.transferState = .idle + } else if case .rejected = self.transferState { + self.transferState = .idle + } + } + } + } + + private func sendAnnouncement() { + let payload: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "port": defaultPort, + // Use HTTP here so LocalSend peers can quickly fail over to UDP response + // when Atoll is not serving LocalSend register endpoint. + "protocol": "http", + "download": false, + "announcement": true, + "announce": true, + ] + guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } + connectionGroup?.send(content: data, completion: { _ in }) + } + + private func startRegisterListenerIfNeeded() { + guard registerListener == nil else { return } + + do { + let params = NWParameters.tcp + params.allowLocalEndpointReuse = true + params.includePeerToPeer = true + + let listener = try NWListener( + using: params, + on: NWEndpoint.Port(integerLiteral: NWEndpoint.Port.IntegerLiteralType(defaultPort)) + ) + + listener.stateUpdateHandler = { state in + if case let .failed(error) = state { + Task { @MainActor [weak self] in + Logger.log("LocalSend register listener failed: \(error.localizedDescription)", category: .extensions) + self?.registerListener = nil + } + } + } + + listener.newConnectionHandler = { [weak self] connection in + Task { @MainActor in + self?.handleRegisterConnection(connection) + } + } + + listener.start(queue: .global(qos: .utility)) + registerListener = listener + } catch { + Logger.log("LocalSend register listener start failed: \(error.localizedDescription)", category: .extensions) + } + } + + private func handleRegisterConnection(_ connection: NWConnection) { + connection.start(queue: .global(qos: .utility)) + connection.receive(minimumIncompleteLength: 1, maximumLength: 131_072) { [weak self] content, _, _, _ in + guard let self, let data = content, !data.isEmpty else { + connection.cancel() + return + } + + Task { @MainActor in + let response = self.processRegisterRequest(data: data, from: connection.endpoint) + connection.send(content: response, completion: .contentProcessed { _ in + connection.cancel() + }) + } + } + } + + private func processRegisterRequest(data: Data, from endpoint: NWEndpoint) -> Data { + guard let request = String(data: data, encoding: .utf8) else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let parts = request.components(separatedBy: "\r\n\r\n") + guard let head = parts.first, + let firstLine = head.components(separatedBy: "\r\n").first + else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let tokens = firstLine.split(separator: " ") + guard tokens.count >= 2 else { + return httpResponse(status: 400, json: ["error": "invalid-request"]) + } + + let method = String(tokens[0]).uppercased() + let rawPath = String(tokens[1]) + let path = rawPath.components(separatedBy: "?").first ?? rawPath + + if method == "POST", path == "/api/localsend/v2/register" || path == "/api/localsend/v3/register" { + let callerIP = endpointIPv4(endpoint) + let bodyText = parts.dropFirst().joined(separator: "\r\n\r\n") + let bodyData = bodyText.data(using: .utf8) + let json = bodyData.flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + + if let json, + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String, + let callerIP, + fingerprint != "atoll.localsend.bridge" { + let device = LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: callerIP, + port: (json["port"] as? Int) ?? defaultPort, + https: (json["protocol"] as? String) == "https", + model: json["deviceModel"] as? String + ) + discoveredByID[fingerprint] = (device, Date()) + rememberKnownPeerIP(callerIP) + rememberRecentProbeIP(callerIP) + refreshDevices() + } else if let callerIP { + // Be tolerant to partial HTTP payloads: still ACK register and probe caller directly. + Task { @MainActor [weak self] in + guard let self else { return } + if let device = await Self.probeDeviceInfo(at: callerIP, port: defaultPort, timeout: 0.35) { + self.discoveredByID[device.id] = (device, Date()) + self.rememberKnownPeerIP(device.ip) + self.rememberRecentProbeIP(device.ip) + self.refreshDevices() + } + } + } + + let responseJSON: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "token": "atoll.localsend.bridge", + "fingerprint": "atoll.localsend.bridge", + "download": false, + "hasWebInterface": false, + ] + return httpResponse(status: 200, json: responseJSON) + } + + if method == "GET", path == "/api/localsend/v2/info" { + let responseJSON: [String: Any] = [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "port": defaultPort, + "protocol": "http", + "download": false, + ] + return httpResponse(status: 200, json: responseJSON) + } + + return httpResponse(status: 404, json: ["error": "not-found"]) + } + + private func endpointIPv4(_ endpoint: NWEndpoint) -> String? { + guard case let .hostPort(host, _) = endpoint else { return nil } + let ip = host.debugDescription.replacingOccurrences(of: "\"", with: "") + return isValidIPv4(ip) ? ip : nil + } + + private func httpResponse(status: Int, json: [String: Any]) -> Data { + let body = (try? JSONSerialization.data(withJSONObject: json)) ?? Data("{}".utf8) + let headers = [ + "HTTP/1.1 \(status) \(status == 200 ? "OK" : "Error")", + "Content-Type: application/json", + "Content-Length: \(body.count)", + "Connection: close", + "", + "", + ].joined(separator: "\r\n") + + var response = Data(headers.utf8) + response.append(body) + return response + } + + private func handleIncoming(content: Data, endpoint: NWEndpoint?) { + guard let json = try? JSONSerialization.jsonObject(with: content) as? [String: Any], + let fingerprint = json["fingerprint"] as? String, + let alias = json["alias"] as? String + else { return } + + if fingerprint == "atoll.localsend.bridge" { return } + + let ip: String + if let announced = json["ip"] as? String { + ip = announced + } else if case let .hostPort(host, _) = endpoint { + ip = host.debugDescription.replacingOccurrences(of: "\"", with: "") + } else { + return + } + + let port = (json["port"] as? Int) ?? defaultPort + let https = (json["protocol"] as? String) == "https" + let model = json["deviceModel"] as? String + + let device = LocalSendDeviceInfo( + id: fingerprint, + alias: alias, + ip: ip, + port: port, + https: https, + model: model + ) + + discoveredByID[fingerprint] = (device, Date()) + rememberKnownPeerIP(ip) + rememberRecentProbeIP(ip) + refreshDevices() + } + + private func cleanupStale() { + let cutoff = Date().addingTimeInterval(-30) + discoveredByID = discoveredByID.filter { $0.value.lastSeen > cutoff } + refreshDevices() + } + + private func refreshDevices() { + devices = discoveredByID.values.map(\.device).sorted { $0.alias.localizedCaseInsensitiveCompare($1.alias) == .orderedAscending } + if !devices.contains(where: { $0.id == selectedDeviceID }), let first = devices.first { + selectedDeviceID = first.id + } + } + + private struct TransferFile { + let id: String + let name: String + let mimeType: String + let data: Data + } + + private func buildTransferFiles(from items: [Any]) async throws -> [TransferFile] { + var result: [TransferFile] = [] + for item in items { + if let url = item as? URL, url.isFileURL { + if let data = try? Data(contentsOf: url) { + result.append(TransferFile( + id: UUID().uuidString, + name: url.lastPathComponent, + mimeType: preferredTransferMimeType(for: url), + data: data + )) + } + } else if let url = item as? URL { + let string = url.absoluteString + result.append(TransferFile( + id: UUID().uuidString, + name: "link.url", + mimeType: "text/uri-list", + data: Data(string.utf8) + )) + } else if let text = item as? String { + result.append(TransferFile( + id: UUID().uuidString, + name: "text.txt", + mimeType: "text/plain", + data: Data(text.utf8) + )) + } + } + return result + } + + private func preferredTransferMimeType(for url: URL) -> String { + let ext = url.pathExtension.lowercased() + if ext == "svg" { + // Some LocalSend receivers try gallery/image-specific write paths for SVG and fail. + return "application/octet-stream" + } + return url.mimeType ?? "application/octet-stream" + } + + private func prepareUpload(files: [TransferFile], to device: LocalSendDeviceInfo) async throws -> (sessionID: String, fileTokens: [String: String]) { + var filesMap: [String: Any] = [:] + for file in files { + filesMap[file.id] = [ + "id": file.id, + "fileName": file.name, + "size": file.data.count, + "fileType": file.mimeType, + ] + } + + let payload: [String: Any] = [ + "info": [ + "alias": Host.current().localizedName ?? "Atoll", + "version": "2.1", + "deviceModel": "Mac", + "deviceType": "desktop", + "fingerprint": "atoll.localsend.bridge", + "token": "atoll.localsend.bridge", + "port": defaultPort, + "protocol": device.https ? "https" : "http", + "download": false, + ], + "files": filesMap, + ] + + var lastError: Error? + for baseURL in candidateBaseURLs(for: device) { + do { + return try await prepareUpload(payload: payload, baseURL: baseURL) + } catch { + lastError = error + Logger.log("LocalSend prepare-upload failed via \(baseURL): \(error.localizedDescription)", category: .extensions) + if !shouldRetryAcrossSchemes(error) { + throw error + } + } + } + + throw lastError ?? LocalSendServiceError.invalidResponse + } + + private func prepareUpload(payload: [String: Any], baseURL: String) async throws -> (sessionID: String, fileTokens: [String: String]) { + guard let url = URL(string: "\(baseURL)/api/localsend/v2/prepare-upload") else { + throw LocalSendServiceError.invalidTarget + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONSerialization.data(withJSONObject: payload) + + let (data, response) = try await trustedSession.data(for: request) + guard let http = response as? HTTPURLResponse else { throw LocalSendServiceError.invalidResponse } + + if http.statusCode == 204 { + return ("", [:]) + } + // HTTP 403 means the transfer was rejected by the recipient + if http.statusCode == 403 { + throw LocalSendServiceError.transferRejected + } + guard (200 ... 299).contains(http.statusCode) else { + let body = String(data: data, encoding: .utf8) + throw LocalSendServiceError.server(status: http.statusCode, body: body) + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let sessionID = json["sessionId"] as? String, + let rawFileTokens = json["files"] as? [String: Any] + else { throw LocalSendServiceError.invalidResponse } + + var fileTokens: [String: String] = [:] + for (k, v) in rawFileTokens { + if let s = v as? String { + fileTokens[k] = s + } + } + + return (sessionID, fileTokens) + } + + private func upload(file: TransferFile, sessionID: String, token: String, to device: LocalSendDeviceInfo, progress: @escaping (Double) -> Void) async throws { + var lastError: Error? + for baseURL in candidateBaseURLs(for: device) { + do { + try await upload(file: file, sessionID: sessionID, token: token, baseURL: baseURL, progress: progress) + return + } catch { + lastError = error + Logger.log("LocalSend upload failed via \(baseURL) for \(file.name): \(error.localizedDescription)", category: .extensions) + if !shouldRetryAcrossSchemes(error) { + throw error + } + } + } + throw lastError ?? LocalSendServiceError.invalidResponse + } + + private func upload(file: TransferFile, sessionID: String, token: String, baseURL: String, progress: @escaping (Double) -> Void) async throws { + var components = URLComponents(string: "\(baseURL)/api/localsend/v2/upload") + components?.queryItems = [ + URLQueryItem(name: "sessionId", value: sessionID), + URLQueryItem(name: "fileId", value: file.id), + URLQueryItem(name: "token", value: token), + ] + + guard let url = components?.url else { throw LocalSendServiceError.invalidTarget } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue(file.mimeType, forHTTPHeaderField: "Content-Type") + request.setValue("\(file.data.count)", forHTTPHeaderField: "Content-Length") + + // Use uploadTask to receive per-byte progress via delegate + let delegate = UploadProgressDelegate { sent, expected in + guard expected > 0 else { return } + let fraction = Double(sent) / Double(expected) + progress(fraction) + } + + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + + let (data, response) = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<(Data, URLResponse), Error>) in + let task = session.uploadTask(with: request, from: file.data) { data, response, error in + if let error = error { + continuation.resume(throwing: error) + return + } + guard let data = data, let response = response else { + continuation.resume(throwing: LocalSendServiceError.invalidResponse) + return + } + continuation.resume(returning: (data, response)) + } + task.resume() + } + + session.finishTasksAndInvalidate() + + guard let http = response as? HTTPURLResponse, (200 ... 299).contains(http.statusCode) else { + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + let body = String(data: data, encoding: .utf8) + throw LocalSendServiceError.server(status: status, body: body) + } + } + + private func candidateBaseURLs(for device: LocalSendDeviceInfo) -> [String] { + let primary = device.baseURL + let alternateScheme = device.https ? "http" : "https" + let alternate = "\(alternateScheme)://\(device.ip):\(device.port)" + return primary == alternate ? [primary] : [primary, alternate] + } + + private func shouldRetryAcrossSchemes(_ error: Error) -> Bool { + if case LocalSendServiceError.server = error { + // A peer responded with a concrete HTTP error; fallback to another scheme is usually noise. + return false + } + + let nsError = error as NSError + if nsError.domain == NSURLErrorDomain { + let code = URLError.Code(rawValue: nsError.code) + switch code { + case .timedOut, .networkConnectionLost, .notConnectedToInternet, + .cannotFindHost, .cannotConnectToHost, .secureConnectionFailed, + .dnsLookupFailed: + return true + default: + return false + } + } + + return false + } + + private lazy var trustedSession: URLSession = { + URLSession(configuration: .default, delegate: LocalSendTLSDelegate(), delegateQueue: nil) + }() +} + +private class LocalSendTLSDelegate: NSObject, URLSessionDelegate { + func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + if let trust = challenge.protectionSpace.serverTrust { + return (.useCredential, URLCredential(trust: trust)) + } + return (.performDefaultHandling, nil) + } +} + +private final class UploadProgressDelegate: LocalSendTLSDelegate, URLSessionTaskDelegate { + private let onProgress: (Int64, Int64) -> Void + + init(onProgress: @escaping (Int64, Int64) -> Void) { + self.onProgress = onProgress + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + Task { @MainActor in + onProgress(totalBytesSent, totalBytesExpectedToSend) + } + } +} + +private extension URL { + var mimeType: String? { + guard !pathExtension.isEmpty, + let type = UTType(filenameExtension: pathExtension) + else { return nil } + return type.preferredMIMEType + } +} + +enum LocalSendServiceError: LocalizedError { + case noDeviceSelected + case noTransferableItems + case invalidTarget + case invalidResponse + case server(status: Int, body: String?) + case transferRejected + + var errorDescription: String? { + switch self { + case .noDeviceSelected: + return "No LocalSend device selected" + case .noTransferableItems: + return "No transferable files or text found" + case .invalidTarget: + return "Invalid LocalSend target" + case .invalidResponse: + return "Invalid response from LocalSend peer" + case .server(let status, let body): + if let body, !body.isEmpty { + return "LocalSend peer error (\(status)): \(body)" + } + return "LocalSend peer error (\(status))" + case .transferRejected: + return "Transfer was rejected by the recipient" + } + } +} \ No newline at end of file diff --git a/DynamicIsland/components/Shelf/Services/QuickShareService.swift b/DynamicIsland/components/Shelf/Services/QuickShareService.swift index 548402f..b77f925 100644 --- a/DynamicIsland/components/Shelf/Services/QuickShareService.swift +++ b/DynamicIsland/components/Shelf/Services/QuickShareService.swift @@ -129,6 +129,10 @@ class QuickShareService: ObservableObject { providers.insert(ad, at: 0) } + if !providers.contains(where: { $0.id == "LocalSend" }) { + providers.insert(QuickShareProvider(id: "LocalSend", imageData: nil, supportsRawText: true), at: min(1, providers.count)) + } + // System Share Menu fallback if !providers.contains(where: { $0.id == "System Share Menu" }) { providers.append(QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)) @@ -137,11 +141,36 @@ class QuickShareService: ObservableObject { return (providers, services) }.value + var providers = result.providers + if let idx = providers.firstIndex(where: { $0.id == "LocalSend" }) { + providers[idx].imageData = localSendIconData() + } + self.cachedServices = result.services - self.availableProviders = result.providers + self.availableProviders = providers self.hasDiscovered = true self.discoveryTask = nil } + + private func localSendIconData() -> Data? { + guard let icon = NSImage(named: NSImage.Name("LocalSend")) else { + return nil + } + let thumbSize = NSSize(width: 32, height: 32) + let thumb = NSImage(size: thumbSize) + thumb.lockFocus() + icon.draw(in: NSRect(origin: .zero, size: thumbSize), + from: NSRect(origin: .zero, size: icon.size), + operation: .copy, + fraction: 1.0) + thumb.unlockFocus() + + guard let tiff = thumb.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) + else { return nil } + + return bitmap.representation(using: .png, properties: [:]) + } // MARK: - File Picker @MainActor @@ -154,6 +183,9 @@ class QuickShareService: ObservableObject { isPickerOpen = true SharingStateManager.shared.beginInteraction() + // Improve interaction in dev/test builds where app activation can be flaky. + NSApp.activate(ignoringOtherApps: true) + let panel = NSOpenPanel() panel.allowsMultipleSelection = true panel.canChooseDirectories = true @@ -174,8 +206,12 @@ class QuickShareService: ObservableObject { } } - let response = panel.runModal() - completion(response) + if let window = view?.window { + panel.beginSheetModal(for: window, completionHandler: completion) + } else { + let response = panel.runModal() + completion(response) + } } // MARK: - Sharing @@ -187,6 +223,21 @@ class QuickShareService: ObservableObject { // Start security-scoped access for all file URLs sharingAccessingURLs = fileURLs.filter { $0.startAccessingSecurityScopedResource() } + if provider.id == "LocalSend" { + SharingStateManager.shared.beginInteraction() + defer { + SharingStateManager.shared.endInteraction() + } + + do { + try await LocalSendService.shared.send(items: items) + } catch { + NSAlert.popError(error) + } + stopSharingAccessingURLs() + return + } + // Setup lifecycle delegate to keep notch open during picker/service let delegate = SharingStateManager.shared.makeDelegate { [weak self] in self?.lifecycleDelegate = nil @@ -210,6 +261,7 @@ class QuickShareService: ObservableObject { } private func stopSharingAccessingURLs() { + guard !sharingAccessingURLs.isEmpty else { return } NSLog("Stopping sharing access to URLs") for url in sharingAccessingURLs { url.stopAccessingSecurityScopedResource() diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift index ab685ff..e1972f8 100644 --- a/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfStateViewModel.swift @@ -49,19 +49,25 @@ final class ShelfStateViewModel: ObservableObject { var merged = items // Deduplicate by identityKey while preserving order (existing first) var seen: Set = Set(merged.map { $0.identityKey }) + var addedIDs: [String] = [] for it in newItems { let key = it.identityKey if !seen.contains(key) { merged.append(it) seen.insert(key) + addedIDs.append(it.id.uuidString) } } items = merged + if !addedIDs.isEmpty { + ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: addedIDs, action: "added") + } } func remove(_ item: ShelfItem) { item.cleanupStoredData() items.removeAll { $0.id == item.id } + ExtensionRPCServer.shared.notifyShelfItemsChanged(itemIDs: [item.id.uuidString], action: "removed") } func updateBookmark(for item: ShelfItem, bookmark: Data) { diff --git a/DynamicIsland/components/Shelf/Views/FileShareView.swift b/DynamicIsland/components/Shelf/Views/FileShareView.swift index 9c0075e..13e2316 100644 --- a/DynamicIsland/components/Shelf/Views/FileShareView.swift +++ b/DynamicIsland/components/Shelf/Views/FileShareView.swift @@ -28,29 +28,49 @@ import UniformTypeIdentifiers struct FileShareView: View { @EnvironmentObject private var vm: DynamicIslandViewModel @StateObject private var quickShare = QuickShareService.shared + @StateObject private var localSend = LocalSendService.shared @Default(.quickShareProvider) var quickShareProvider: String + @State private var showQuickSharePopover = false + @State private var isSwitchHover = false + @State private var autoCloseToken = UUID() @State private var hostView: NSView? @State private var interactionNonce: UUID = .init() @State private var isProcessing = false + @State private var pendingDropProviders: [NSItemProvider]? + @State private var showLocalSendPicker = false private var selectedProvider: QuickShareProvider { quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true) } + private var notchToggleProviders: [QuickShareProvider] { + quickShare.availableProviders.filter { $0.id == "AirDrop" || $0.id == "LocalSend" } + } + var body: some View { dropArea .background(NSViewHost(view: $hostView)) - .onAppear { quickShare.ensureDiscovered() } + .onAppear { + quickShare.ensureDiscovered() + localSend.startDiscovery() + } .onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in interactionNonce = .init() vm.dropEvent = true - Task { await handleDrop(providers) } + if selectedProvider.id == "LocalSend" { + pendingDropProviders = providers + showLocalSendPicker = true + } else { + Task { await handleDrop(providers) } + } return true } .onTapGesture { - Task { - await handleClick() + guard quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) != nil else { return } + // Only open picker on taps when AirDrop or LocalSend is selected + if quickShareProvider == "AirDrop" || quickShareProvider == "LocalSend" { + Task { await handleClick() } } } } @@ -85,12 +105,16 @@ struct FileShareView: View { if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) { Image(nsImage: nsImg) .resizable() - .aspectRatio(contentMode: .fit) + .scaledToFit() + .frame(width: 34, height: 34) + .clipped() } else { Image(systemName: "square.and.arrow.up") + .resizable() + .scaledToFit() + .frame(width: 34, height: 34) } } - .frame(width: 34, height: 34) .foregroundStyle( vm.dropZoneTargeting ? Color.accentColor : Color.gray ) @@ -104,11 +128,116 @@ struct FileShareView: View { .font(.system(.headline, design: .rounded)) .foregroundColor(.white.opacity(0.8)) .multilineTextAlignment(.center) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity) } .padding(18) .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 if isProcessing || quickShare.isPickerOpen { @@ -120,8 +249,63 @@ struct FileShareView: View { .scaleEffect(0.8) ) } + + if selectedProvider.id == "LocalSend" && localSend.isSending { + VStack { + HStack { + Spacer() + ZStack { + Circle() + .stroke(Color.white.opacity(0.16), lineWidth: 3) + + Circle() + .trim(from: 0, to: min(max(localSend.sendProgress, 0), 1)) + .stroke( + Color.white, + style: StrokeStyle(lineWidth: 3, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: localSend.sendProgress) + + if localSend.sendProgress > 0.99 { + Image(systemName: "checkmark") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(.white) + } + } + .frame(width: 24, height: 24) + .padding(.top, 10) + .padding(.trailing, 10) + } + Spacer() + } + .transition(.opacity) + } } .contentShape(RoundedRectangle(cornerRadius: 12)) + .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 diff --git a/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift b/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift new file mode 100644 index 0000000..15a72a2 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/LocalSendLiveActivity.swift @@ -0,0 +1,173 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import Defaults + +struct LocalSendLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @StateObject private var localSend = LocalSendService.shared + + @State private var isHovering: Bool = false + @State private var isExpanded: Bool = false + + private var tint: Color { + switch localSend.transferState { + case .completed: + return .green + case .failed, .rejected: + return .red + default: + return .accentColor + } + } + + private var isActive: Bool { + localSend.isSending || localSend.transferState == .completed || + isFailedOrRejected + } + + private var isFailedOrRejected: Bool { + if case .failed = localSend.transferState { return true } + if case .rejected = localSend.transferState { return true } + return false + } + + var body: some View { + HStack(spacing: 0) { + // Left side: upload icon capsule + Color.clear + .background { + if isExpanded { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(tint.opacity(0.14)) + + Image(systemName: leftIcon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + } + .frame( + width: vm.effectiveClosedNotchHeight - 12, + height: vm.effectiveClosedNotchHeight - 12 + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + .frame( + width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + + // Center: closed notch body (slightly wider during transfers) + Rectangle() + .fill(.black) + .frame( + width: vm.closedNotchSize.width + + (isHovering ? 8 : 0) + + (isActive ? 40 : 0) + ) + + // Right side: progress ring or status icon + Color.clear + .background { + if isExpanded { + HStack { + rightContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + } + .frame( + width: isExpanded ? max(60, vm.effectiveClosedNotchHeight) : 0, + height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + ) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + withAnimation(.smooth(duration: 0.35)) { + isExpanded = true + } + } + .onChange(of: isActive) { _, newValue in + withAnimation(.smooth(duration: 0.35)) { + isExpanded = newValue + } + } + } + + private var leftIcon: String { + // Always show document upload icon on left side + return "document.badge.arrow.up.fill" + } + + @ViewBuilder + private var rightContent: some View { + switch localSend.transferState { + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .failed: + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.red) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .rejected: + Image(systemName: "hand.raised.fill") + .foregroundStyle(.orange) + .font(.system(size: 16, weight: .semibold)) + .padding(.trailing, 6) + case .sending, .idle: + LocalSendProgressRing(progress: localSend.sendProgress) + .padding(.trailing, 6) + } + } +} + +// MARK: - Progress Ring + +private struct LocalSendProgressRing: View { + let progress: Double + + var body: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.2), lineWidth: 2.5) + + Circle() + .trim(from: 0, to: min(max(progress, 0), 1)) + .stroke( + Color.accentColor, + style: StrokeStyle(lineWidth: 2.5, lineCap: .round) + ) + .rotationEffect(.degrees(-90)) + .animation(.easeInOut(duration: 0.2), value: progress) + + if progress > 0.99 { + Image(systemName: "checkmark") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(.accentColor) + } + } + .frame(width: 20, height: 20) + } +} diff --git a/DynamicIsland/components/Stats/CPUStatsSharedViews.swift b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift index 29b96be..773a788 100644 --- a/DynamicIsland/components/Stats/CPUStatsSharedViews.swift +++ b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift @@ -77,10 +77,10 @@ struct CPUUsageDashboard: View { Text("Usage") .font(.caption) .foregroundColor(.secondary) - DetailRow(color: systemColor.opacity(0.95), label: "System", value: StatsFormatting.percentage(breakdown.system)) - DetailRow(color: userColor.opacity(0.95), label: "User", value: StatsFormatting.percentage(breakdown.user)) - DetailRow(color: idleColor.opacity(0.7), label: "Idle", value: StatsFormatting.percentage(breakdown.idle)) - DetailRow(color: nil, label: "Active", value: StatsFormatting.percentage(breakdown.activeUsage)) + DetailRow(color: systemColor.opacity(0.95), label: String(localized: "System"), value: StatsFormatting.percentage(breakdown.system)) + DetailRow(color: userColor.opacity(0.95), label: String(localized: "User"), value: StatsFormatting.percentage(breakdown.user)) + DetailRow(color: idleColor.opacity(0.7), label: String(localized: "Idle"), value: StatsFormatting.percentage(breakdown.idle)) + DetailRow(color: nil, label: String(localized: "stats_resource_active", defaultValue: "Active"), value: StatsFormatting.percentage(breakdown.activeUsage)) } .frame(maxWidth: .infinity, alignment: .leading) @@ -171,7 +171,7 @@ private struct CPUTemperatureGauge: View { var body: some View { CircularGaugeView( - title: "Temperature", + title: String(localized: "Temperature"), value: normalizedValue, tint: tint, centerPrimaryText: centerPrimary, diff --git a/DynamicIsland/components/Stats/GPUStatsDetailView.swift b/DynamicIsland/components/Stats/GPUStatsDetailView.swift index 064d2b5..1cac62a 100644 --- a/DynamicIsland/components/Stats/GPUStatsDetailView.swift +++ b/DynamicIsland/components/Stats/GPUStatsDetailView.swift @@ -133,7 +133,7 @@ private struct GPUUsageDashboard: View { .font(.caption) .foregroundColor(.secondary) DetailRow(color: nil, label: String(localized: "Session Avg"), value: StatsFormatting.percentage(averageUsage)) - DetailRow(color: nil, label: String("localized: Last Update"), value: formattedTimestamp(lastUpdated)) + DetailRow(color: nil, label: String(localized: "Last Update"), value: formattedTimestamp(lastUpdated)) if let device = primaryDevice { Divider().padding(.vertical, 4) DetailRow(color: nil, label: String(localized: "Active GPU"), value: device.formattedVendorModel) diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index 03a0d09..33939fc 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -23,6 +23,7 @@ import AtollExtensionKit import SwiftUI import Defaults +import AppKit struct TabModel: Identifiable { let id: String @@ -45,6 +46,9 @@ struct TabModel: Identifiable { struct TabSelectionView: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared + @StateObject private var quickShareService = QuickShareService.shared + @Default(.quickShareProvider) private var quickShareProvider + @State private var showQuickSharePopover = false @Default(.enableTimerFeature) var enableTimerFeature @Default(.enableStatsFeature) var enableStatsFeature @Default(.enableColorPickerFeature) var enableColorPickerFeature @@ -106,9 +110,11 @@ struct TabSelectionView: View { } var body: some View { HStack(spacing: 0) { - ForEach(tabs) { tab in + ForEach(Array(tabs.enumerated()), id: \.element.id) { idx, tab in let isSelected = isSelected(tab) let activeAccent = tab.accentColor ?? .white + + // Render the tab button TabButton(label: tab.label, icon: tab.icon, selected: isSelected) { if tab.view == .extensionExperience { coordinator.selectedExtensionExperienceID = tab.experienceID @@ -130,6 +136,8 @@ struct TabSelectionView: View { .hidden() } } + + } } .animation(.smooth(duration: 0.3), value: coordinator.currentView) diff --git a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift index 2c3501c..c4ca4b6 100644 --- a/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionAuthorizationManager.swift @@ -111,6 +111,8 @@ final class ExtensionAuthorizationManager: ObservableObject { record.widgetTimestamps.append(now) case .notchExperiences: record.notchExperienceTimestamps.append(now) + case .fileSharing: + record.activityTimestamps.append(now) } record.activityTimestamps = flushOldTimestamps(record.activityTimestamps) record.widgetTimestamps = flushOldTimestamps(record.widgetTimestamps) diff --git a/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift new file mode 100644 index 0000000..387bdf1 --- /dev/null +++ b/DynamicIsland/managers/LocalSendDevicePickerWindowManager.swift @@ -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 . + */ + +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) + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 628253f..c03afc5 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -143,12 +143,14 @@ enum ExtensionPermissionScope: String, CaseIterable, Codable, Defaults.Serializa case liveActivities case lockScreenWidgets case notchExperiences + case fileSharing var displayName: String { switch self { case .liveActivities: return "Live Activities" case .lockScreenWidgets: return "Lock Screen Widgets" case .notchExperiences: return "Notch Experiences" + case .fileSharing: return "File Sharing" } } } @@ -886,6 +888,9 @@ extension Defaults.Keys { static let dynamicShelf = Key("dynamicShelf", default: true) static let openShelfByDefault = Key("openShelfByDefault", default: true) static let quickShareProvider = Key("quickShareProvider", default: "AirDrop") + static let localSendSelectedDeviceID = Key("localSendSelectedDeviceID", default: "") + static let localSendDevicePickerGlassMode = Key("localSendDevicePickerGlassMode", default: .standard) + static let localSendDevicePickerLiquidGlassVariant = Key("localSendDevicePickerLiquidGlassVariant", default: .v11) static let copyOnDrag = Key("copyOnDrag", default: false) static let autoRemoveShelfItems = Key("autoRemoveShelfItems", default: false) static let expandedDragDetection = Key("expandedDragDetection", default: true) @@ -1003,6 +1008,7 @@ extension Defaults.Keys { static let extensionLiveActivityCapacity = Key("extensionLiveActivityCapacity", default: 4) static let extensionLockScreenWidgetCapacity = Key("extensionLockScreenWidgetCapacity", default: 4) static let extensionNotchExperienceCapacity = Key("extensionNotchExperienceCapacity", default: 2) + static let enableExtensionFileSharing = Key("enableExtensionFileSharing", default: true) // MARK: Keyboard Shortcuts static let enableShortcuts = Key("enableShortcuts", default: true) diff --git a/DynamicIsland/models/MusicControlButton.swift b/DynamicIsland/models/MusicControlButton.swift index 3d2bd23..fb5555b 100644 --- a/DynamicIsland/models/MusicControlButton.swift +++ b/DynamicIsland/models/MusicControlButton.swift @@ -76,27 +76,27 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S var label: String { switch self { case .shuffle: - return "Shuffle" + return String(localized: "Shuffle") case .trackBackward: - return "Previous Track" + return String(localized: "Previous Track") case .playPause: - return "Play / Pause" + return String(localized: "Play / Pause") case .trackForward: - return "Next Track" + return String(localized: "Next Track") case .repeatMode: - return "Repeat" + return String(localized: "Repeat") case .mediaOutput: - return "Change Media Output" + return String(localized: "Change Media Output") case .airPlay: - return "AirPlay" + return String(localized: "AirPlay") case .lyrics: - return "Lyrics" + return String(localized: "Lyrics") case .seekBackward: - return "Rewind 10s" + return String(localized: "Rewind 10s") case .seekForward: - return "Forward 10s" + return String(localized: "Forward 10s") case .none: - return "Empty Slot" + return String(localized: "Empty Slot") } } diff --git a/DynamicIsland/services/Extensions/ExtensionRPCServer.swift b/DynamicIsland/services/Extensions/ExtensionRPCServer.swift index 13a9dd2..c1bc1b5 100644 --- a/DynamicIsland/services/Extensions/ExtensionRPCServer.swift +++ b/DynamicIsland/services/Extensions/ExtensionRPCServer.swift @@ -29,6 +29,7 @@ final class ExtensionRPCServer { private var listener: NWListener? private var connections: [UUID: RPCClientConnection] = [:] + private var shelfSubscribers: Set = [] // bundleIdentifiers subscribed to shelf events private let port: UInt16 = 9020 private let queue = DispatchQueue(label: "com.ebullioscopic.Atoll.rpc.server", qos: .userInitiated) private let decoder = JSONDecoder() @@ -78,6 +79,7 @@ final class ExtensionRPCServer { conn.connection.cancel() } connections.removeAll() + shelfSubscribers.removeAll() logDiagnostics("RPC server stopped") } @@ -127,6 +129,29 @@ final class ExtensionRPCServer { ) } + // MARK: - Shelf Event Subscriptions + + func registerShelfSubscription(for bundleIdentifier: String) { + shelfSubscribers.insert(bundleIdentifier) + logDiagnostics("Registered shelf subscription for \(bundleIdentifier)") + } + + func notifyShelfItemsChanged(itemIDs: [String], action: String) { + guard !shelfSubscribers.isEmpty else { return } + let params: [String: RPCValue] = [ + "action": .string(action), + "itemIDs": .array(itemIDs.map { .string($0) }) + ] + for subscriber in shelfSubscribers { + sendNotification( + to: subscriber, + method: "atoll.shelfItemsDidChange", + params: params + ) + } + logDiagnostics("Notified \(shelfSubscribers.count) subscriber(s) of shelf change (\(action), \(itemIDs.count) items)") + } + // MARK: - Connection Handling private func handleListenerState(_ state: NWListener.State) { diff --git a/DynamicIsland/services/Extensions/ExtensionRPCService.swift b/DynamicIsland/services/Extensions/ExtensionRPCService.swift index 383011e..a72757a 100644 --- a/DynamicIsland/services/Extensions/ExtensionRPCService.swift +++ b/DynamicIsland/services/Extensions/ExtensionRPCService.swift @@ -17,8 +17,10 @@ */ import Foundation +import AppKit import Defaults import AtollExtensionKit +import UniformTypeIdentifiers /// Handles JSON-RPC method calls for a single WebSocket connection. /// Mirrors the functionality of `ExtensionXPCService` but uses JSON-RPC transport. @@ -94,6 +96,25 @@ final class ExtensionRPCService { case "atoll.dismissNotchExperience": result = handleDismissNotchExperience(params: request.params, id: request.id) + // MARK: File Sharing + case "atoll.getShelfItems": + result = handleGetShelfItems(params: request.params, id: request.id) + + case "atoll.getShelfItemData": + result = handleGetShelfItemData(params: request.params, id: request.id) + + case "atoll.showFilePicker": + result = handleShowFilePicker(params: request.params, id: request.id) + + case "atoll.shareShelfItems": + result = handleShareShelfItems(params: request.params, id: request.id) + + case "atoll.addFilesToShelf": + result = handleAddFilesToShelf(params: request.params, id: request.id) + + case "atoll.subscribeShelfEvents": + result = handleSubscribeShelfEvents(params: request.params, id: request.id) + default: result = RPCErrorResponse( error: RPCErrorObject(code: RPCErrorCode.methodNotFound, message: "Method not found: \(request.method)"), @@ -327,7 +348,7 @@ final class ExtensionRPCService { case .duplicateIdentifier: code = RPCErrorCode.descriptorInvalid } return RPCErrorResponse( - error: RPCErrorObject(code: code, message: error.localizedDescription ?? "Unknown error"), + error: RPCErrorObject(code: code, message: error.localizedDescription), id: id ) } @@ -337,6 +358,232 @@ final class ExtensionRPCService { Logger.log(message, category: .extensions) } + // MARK: - File Sharing Authorization Check + + private func checkFileSharingAuthorization(id: String) -> RPCErrorResponse? { + guard Defaults[.enableThirdPartyExtensions] else { + return errorResponse(code: RPCErrorCode.featureDisabled, message: "Extensions are disabled", id: id) + } + guard Defaults[.enableExtensionFileSharing] else { + return errorResponse(code: RPCErrorCode.featureDisabled, message: "Extension file sharing is disabled", id: id) + } + let entry = authorizationManager.authorizationEntry(for: bundleIdentifier) + guard entry?.isAuthorized == true else { + return errorResponse(code: RPCErrorCode.unauthorized, message: "Extension not authorized", id: id) + } + guard entry?.allowedScopes.contains(.fileSharing) == true else { + return errorResponse(code: RPCErrorCode.unauthorized, message: "File sharing scope not granted", id: id) + } + return nil + } + + // MARK: - File Sharing Handlers + + private func handleGetShelfItems(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + let items = ShelfStateViewModel.shared.items + var result: [RPCValue] = [] + + for item in items { + var entry: [String: RPCValue] = [ + "id": .string(item.id.uuidString), + "name": .string(item.displayName) + ] + switch item.kind { + case .file(let bookmark): + entry["kind"] = .string("file") + if let url = Bookmark(data: bookmark).resolveURL() { + entry["path"] = .string(url.path) + if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int { + entry["size"] = .int(size) + } + } + case .text(let string): + entry["kind"] = .string("text") + entry["textContent"] = .string(string) + case .link(let url): + entry["kind"] = .string("link") + entry["url"] = .string(url.absoluteString) + } + result.append(.object(entry)) + } + + logDiagnostics("RPC: getShelfItems returned \(result.count) items for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["items": .array(result)], id: id) + } + + private func handleGetShelfItemData(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + guard let itemID = params?["itemID"]?.stringValue, + let uuid = UUID(uuidString: itemID) else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing or invalid itemID", id: id) + } + + guard let item = ShelfStateViewModel.shared.items.first(where: { $0.id == uuid }) else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Item not found", id: id) + } + + switch item.kind { + case .file(let bookmark): + guard let url = Bookmark(data: bookmark).resolveURL() else { + return errorResponse(code: RPCErrorCode.internalError, message: "Cannot resolve file bookmark", id: id) + } + guard let fileData = url.accessSecurityScopedResource(accessor: { try? Data(contentsOf: $0) }) else { + return errorResponse(code: RPCErrorCode.internalError, message: "Cannot read file data", id: id) + } + let base64 = fileData.base64EncodedString() + logDiagnostics("RPC: getShelfItemData returned \(fileData.count) bytes for \(bundleIdentifier)") + return RPCSuccessResponse(result: [ + "data": .string(base64), + "fileName": .string(url.lastPathComponent), + "mimeType": .string(url.mimeType ?? "application/octet-stream") + ], id: id) + case .text(let string): + return RPCSuccessResponse(result: [ + "data": .string(Data(string.utf8).base64EncodedString()), + "fileName": .string("text.txt"), + "mimeType": .string("text/plain") + ], id: id) + case .link(let url): + return RPCSuccessResponse(result: [ + "data": .string(Data(url.absoluteString.utf8).base64EncodedString()), + "fileName": .string("link.url"), + "mimeType": .string("text/uri-list") + ], id: id) + } + } + + private func handleShowFilePicker(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + let panel = NSOpenPanel() + panel.allowsMultipleSelection = true + panel.canChooseDirectories = true + panel.canChooseFiles = true + panel.title = "Select files to share" + + let response = panel.runModal() + guard response == .OK, !panel.urls.isEmpty else { + return RPCSuccessResponse(result: ["itemIDs": .array([])], id: id) + } + + var newItemIDs: [RPCValue] = [] + var newItems: [ShelfItem] = [] + + for url in panel.urls { + if let bookmark = try? Bookmark(url: url) { + let item = ShelfItem(kind: .file(bookmark: bookmark.data)) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + } + + ShelfStateViewModel.shared.add(newItems) + logDiagnostics("RPC: showFilePicker added \(newItems.count) items for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["itemIDs": .array(newItemIDs)], id: id) + } + + private func handleShareShelfItems(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + guard let itemIDsValue = params?["itemIDs"], + case .array(let itemIDArray) = itemIDsValue else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Missing itemIDs array", id: id) + } + + let provider = params?["provider"]?.stringValue ?? "AirDrop" + let itemIDs = itemIDArray.compactMap { $0.stringValue }.compactMap { UUID(uuidString: $0) } + let items = ShelfStateViewModel.shared.items.filter { itemIDs.contains($0.id) } + + guard !items.isEmpty else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "No matching shelf items found", id: id) + } + + let urls = ShelfStateViewModel.shared.resolveFileURLs(for: items) + guard !urls.isEmpty else { + return errorResponse(code: RPCErrorCode.internalError, message: "Could not resolve file URLs", id: id) + } + + // Find and invoke the sharing service + QuickShareService.shared.ensureDiscovered() + if let shareProvider = QuickShareService.shared.availableProviders.first(where: { $0.id == provider }) { + Task { + await QuickShareService.shared.shareFilesOrText(urls, using: shareProvider, from: nil) + } + logDiagnostics("RPC: shareShelfItems sharing \(urls.count) files via \(provider) for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["success": .bool(true), "fileCount": .int(urls.count)], id: id) + } else { + return errorResponse(code: RPCErrorCode.invalidParams, message: "Provider '\(provider)' not found", id: id) + } + } + + private func handleAddFilesToShelf(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + var newItems: [ShelfItem] = [] + var newItemIDs: [RPCValue] = [] + + // Handle file paths + if let pathsValue = params?["paths"], + case .array(let pathArray) = pathsValue { + for pathValue in pathArray { + guard let path = pathValue.stringValue else { continue } + let url = URL(fileURLWithPath: path) + guard FileManager.default.fileExists(atPath: path) else { continue } + if let bookmark = try? Bookmark(url: url) { + let item = ShelfItem(kind: .file(bookmark: bookmark.data)) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + } + } + + // Handle base64-encoded file data + if let filesValue = params?["files"], + case .array(let filesArray) = filesValue { + for fileValue in filesArray { + guard case .object(let fileObj) = fileValue, + let dataStr = fileObj["data"]?.stringValue, + let fileName = fileObj["fileName"]?.stringValue, + let fileData = Data(base64Encoded: dataStr) else { continue } + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("AtollExtensionFiles", isDirectory: true) + try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let fileURL = tempDir.appendingPathComponent(fileName) + guard (try? fileData.write(to: fileURL)) != nil else { continue } + + if let bookmark = try? Bookmark(url: fileURL) { + let item = ShelfItem(kind: .file(bookmark: bookmark.data), isTemporary: true) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + } + } + + // Handle text content + if let text = params?["text"]?.stringValue, !text.isEmpty { + let item = ShelfItem(kind: .text(string: text)) + newItems.append(item) + newItemIDs.append(.string(item.id.uuidString)) + } + + ShelfStateViewModel.shared.add(newItems) + logDiagnostics("RPC: addFilesToShelf added \(newItems.count) items for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["itemIDs": .array(newItemIDs)], id: id) + } + + private func handleSubscribeShelfEvents(params: RPCParams?, id: String) -> Codable { + if let err = checkFileSharingAuthorization(id: id) { return err } + + server?.registerShelfSubscription(for: bundleIdentifier) + logDiagnostics("RPC: subscribeShelfEvents registered for \(bundleIdentifier)") + return RPCSuccessResponse(result: ["subscribed": .bool(true)], id: id) + } + // MARK: - Client JSON Transformation /// Converts client wire format to Swift Codable format for enum types. @@ -452,3 +699,12 @@ final class ExtensionRPCService { return [typeName: inner] } } + +// MARK: - URL MIME Type Helper + +private extension URL { + var mimeType: String? { + guard let uti = UTType(filenameExtension: self.pathExtension) else { return nil } + return uti.preferredMIMEType + } +}