From bd1d382436608f3056a4dbdc8e896a10b1550615 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 4 Sep 2026 12:19:40 +0200 Subject: [PATCH 1/6] feat(app-store): add sandboxed App Store build configuration --- Apps/Keyty/Project.swift | 26 +++++++++++++++++++ .../Keyty/Resources/AppStore.entitlements | 8 ++++++ Docs/BUILD.md | 19 ++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 Apps/Keyty/Sources/Keyty/Resources/AppStore.entitlements diff --git a/Apps/Keyty/Project.swift b/Apps/Keyty/Project.swift index 8200edd7..7657999b 100644 --- a/Apps/Keyty/Project.swift +++ b/Apps/Keyty/Project.swift @@ -55,6 +55,16 @@ let appReleaseSettings: SettingsDictionary = [ "GCC_MODEL_TUNING": "G5", ] +// This configuration is intentionally separate from Release. Release produces +// the Developer ID-signed, notarized build used for direct distribution. +// AppStore is the sandboxed build that will be archived for App Store Connect. +let appStoreSettings: SettingsDictionary = [ + "CODE_SIGN_ENTITLEMENTS": "Sources/Keyty/Resources/AppStore.entitlements", + "CODE_SIGN_STYLE": "Automatic", + "DEVELOPMENT_TEAM": "NEVA4MAZBL", + "GCC_MODEL_TUNING": "G5", +] + let testSettings: SettingsDictionary = [ "BUNDLE_LOADER": "$(TEST_HOST)", "CLANG_ANALYZER_NONNULL": "YES", @@ -164,6 +174,12 @@ let projectSettings = Settings.settings( "SWIFT_COMPILATION_MODE": "wholemodule", ] ), + .release( + name: "AppStore", + settings: [ + "SWIFT_COMPILATION_MODE": "wholemodule", + ] + ), ] ) @@ -222,6 +238,7 @@ let project = Project( configurations: [ .debug(name: "Debug", settings: appDebugSettings), .release(name: "Release", settings: appReleaseSettings), + .release(name: "AppStore", settings: appStoreSettings), ] ) ), @@ -265,6 +282,15 @@ let project = Project( profileAction: .profileAction(configuration: .release), analyzeAction: .analyzeAction(configuration: .debug) ), + .scheme( + name: "Keyty AppStore", + shared: true, + buildAction: .buildAction(targets: ["Keyty"]), + runAction: .runAction(configuration: "AppStore"), + archiveAction: .archiveAction(configuration: "AppStore"), + profileAction: .profileAction(configuration: "AppStore"), + analyzeAction: .analyzeAction(configuration: .debug) + ), ], resourceSynthesizers: [ .custom( diff --git a/Apps/Keyty/Sources/Keyty/Resources/AppStore.entitlements b/Apps/Keyty/Sources/Keyty/Resources/AppStore.entitlements new file mode 100644 index 00000000..852fa1a4 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Resources/AppStore.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/Docs/BUILD.md b/Docs/BUILD.md index 77079546..24d66865 100644 --- a/Docs/BUILD.md +++ b/Docs/BUILD.md @@ -67,6 +67,25 @@ xcodebuild build \ -configuration Release ``` +### App Store build + +The **Keyty AppStore** scheme uses the separate `AppStore` configuration and +enables the App Sandbox. It is intentionally separate from `Release`, which +continues to produce the Developer ID build for direct distribution. + +```bash +cd Apps/Keyty +tuist generate +xcodebuild build \ + -project Keyty.xcodeproj \ + -scheme 'Keyty AppStore' \ + -configuration AppStore +``` + +Use the App Store distribution option in Xcode Organizer to archive and upload +an AppStore build once the App Store-specific feature and signing work is +complete. + ### Archive (for distribution) ```bash From 6f58100e3dd418754df00f7045c1eb596bf52c86 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 4 Sep 2026 12:45:04 +0200 Subject: [PATCH 2/6] feat(app-store): add dedicated sandboxed app target --- Apps/Keyty/Project.swift | 37 ++++++++++++++++++- .../Sources/Keyty/App/Support/L10n.swift | 4 ++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/Apps/Keyty/Project.swift b/Apps/Keyty/Project.swift index 7657999b..84b33fdc 100644 --- a/Apps/Keyty/Project.swift +++ b/Apps/Keyty/Project.swift @@ -63,6 +63,11 @@ let appStoreSettings: SettingsDictionary = [ "CODE_SIGN_STYLE": "Automatic", "DEVELOPMENT_TEAM": "NEVA4MAZBL", "GCC_MODEL_TUNING": "G5", + "PRODUCT_BUNDLE_IDENTIFIER": "app.keyty.Keyty.AppStore", + "SWIFT_ACTIVE_COMPILATION_CONDITIONS": [ + "$(inherited)", + "APP_STORE", + ], ] let testSettings: SettingsDictionary = [ @@ -238,6 +243,36 @@ let project = Project( configurations: [ .debug(name: "Debug", settings: appDebugSettings), .release(name: "Release", settings: appReleaseSettings), + ] + ) + ), + .target( + name: "KeytyAppStore", + destinations: .macOS, + product: .app, + bundleId: "app.keyty.Keyty.AppStore", + deploymentTargets: .macOS("11.0"), + infoPlist: .file(path: "Sources/Keyty/Resources/Info.plist"), + buildableFolders: [ + .folder( + "Sources/Keyty", + exceptions: [.exception(excluded: ["Resources/Info.plist"])] + ), + ], + entitlements: .file(path: "Sources/Keyty/Resources/AppStore.entitlements"), + dependencies: [ + .sdk(name: "Cocoa", type: .framework), + .sdk(name: "Quartz", type: .framework), + .sdk(name: "QuartzCore", type: .framework), + .sdk(name: "Carbon", type: .framework), + .package(product: "ShortcutRecorder"), + .package(product: "Sparkle"), + .package(product: "AppMover"), + ], + settings: .settings( + base: appSettings, + configurations: [ + .debug(name: "Debug", settings: appStoreSettings), .release(name: "AppStore", settings: appStoreSettings), ] ) @@ -285,7 +320,7 @@ let project = Project( .scheme( name: "Keyty AppStore", shared: true, - buildAction: .buildAction(targets: ["Keyty"]), + buildAction: .buildAction(targets: ["KeytyAppStore"]), runAction: .runAction(configuration: "AppStore"), archiveAction: .archiveAction(configuration: "AppStore"), profileAction: .profileAction(configuration: "AppStore"), diff --git a/Apps/Keyty/Sources/Keyty/App/Support/L10n.swift b/Apps/Keyty/Sources/Keyty/App/Support/L10n.swift index 196aa0cc..64226200 100644 --- a/Apps/Keyty/Sources/Keyty/App/Support/L10n.swift +++ b/Apps/Keyty/Sources/Keyty/App/Support/L10n.swift @@ -6,4 +6,8 @@ // SPDX-License-Identifier: BSD-3-Clause // +#if APP_STORE +typealias L10n = KeytyAppStoreStrings.Localizable +#else typealias L10n = KeytyStrings.Localizable +#endif From 9d757ec151e343e36cc595e605cc4c27a539b3ae Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 4 Sep 2026 13:13:48 +0200 Subject: [PATCH 3/6] feat(app-store): separate Store distribution from direct updates --- Apps/Keyty/Project.swift | 22 +++-- .../App/Composition/AppDependencies.swift | 5 +- .../App/Composition/AppUIContainer.swift | 6 +- .../Keyty/App/Lifecycle/AppController.swift | 20 ++-- .../Features/About/AboutWindowViewModel.swift | 54 ++++++----- .../Features/Settings/SettingsContext.swift | 8 +- .../Settings/SettingsPaneRegistry.swift | 21 +++-- .../Settings/SettingsWindowController.swift | 1 - .../Settings/Update/UpdateSettingsPane.swift | 5 +- .../Update/UpdateSettingsPaneViewModel.swift | 20 ++-- .../Keyty/Resources/AppStore-Info.plist | 38 ++++++++ .../Services/Updates/UpdateService.swift | 91 +++++++++++++++++++ 12 files changed, 220 insertions(+), 71 deletions(-) create mode 100644 Apps/Keyty/Sources/Keyty/Resources/AppStore-Info.plist create mode 100644 Apps/Keyty/Sources/Keyty/Services/Updates/UpdateService.swift diff --git a/Apps/Keyty/Project.swift b/Apps/Keyty/Project.swift index 84b33fdc..bd233048 100644 --- a/Apps/Keyty/Project.swift +++ b/Apps/Keyty/Project.swift @@ -70,6 +70,12 @@ let appStoreSettings: SettingsDictionary = [ ], ] +let appStoreDebugSettings = appStoreSettings.merging([ + "PRODUCT_NAME": "KeytyAppStore", +]) { _, appStoreDebugValue in + appStoreDebugValue +} + let testSettings: SettingsDictionary = [ "BUNDLE_LOADER": "$(TEST_HOST)", "CLANG_ANALYZER_NONNULL": "YES", @@ -225,7 +231,10 @@ let project = Project( buildableFolders: [ .folder( "Sources/Keyty", - exceptions: [.exception(excluded: ["Resources/Info.plist"])] + exceptions: [.exception(excluded: [ + "Resources/Info.plist", + "Resources/AppStore-Info.plist", + ])] ), ], entitlements: .file(path: "Sources/Keyty/Resources/Keyty.entitlements"), @@ -252,11 +261,14 @@ let project = Project( product: .app, bundleId: "app.keyty.Keyty.AppStore", deploymentTargets: .macOS("11.0"), - infoPlist: .file(path: "Sources/Keyty/Resources/Info.plist"), + infoPlist: .file(path: "Sources/Keyty/Resources/AppStore-Info.plist"), buildableFolders: [ .folder( "Sources/Keyty", - exceptions: [.exception(excluded: ["Resources/Info.plist"])] + exceptions: [.exception(excluded: [ + "Resources/Info.plist", + "Resources/AppStore-Info.plist", + ])] ), ], entitlements: .file(path: "Sources/Keyty/Resources/AppStore.entitlements"), @@ -266,13 +278,11 @@ let project = Project( .sdk(name: "QuartzCore", type: .framework), .sdk(name: "Carbon", type: .framework), .package(product: "ShortcutRecorder"), - .package(product: "Sparkle"), - .package(product: "AppMover"), ], settings: .settings( base: appSettings, configurations: [ - .debug(name: "Debug", settings: appStoreSettings), + .debug(name: "Debug", settings: appStoreDebugSettings), .release(name: "AppStore", settings: appStoreSettings), ] ) diff --git a/Apps/Keyty/Sources/Keyty/App/Composition/AppDependencies.swift b/Apps/Keyty/Sources/Keyty/App/Composition/AppDependencies.swift index b565eee5..2e895fe6 100644 --- a/Apps/Keyty/Sources/Keyty/App/Composition/AppDependencies.swift +++ b/Apps/Keyty/Sources/Keyty/App/Composition/AppDependencies.swift @@ -7,7 +7,6 @@ // import AppKit -import Sparkle @MainActor final class AppDependencies { @@ -23,7 +22,7 @@ final class AppDependencies { init( statusShortcutItem: NSMenuItem, - updater: SPUUpdater, + updateService: any UpdateService, keyValueStore: KeyValueStore = UserDefaultsStore() ) { self.settings = AppSettingsContainer(store: keyValueStore) @@ -34,7 +33,7 @@ final class AppDependencies { self.ui = AppUIContainer( settings: self.settings, services: self.services, - updater: updater + updateService: updateService ) } } diff --git a/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift b/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift index a647a718..be14a5c2 100644 --- a/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift +++ b/Apps/Keyty/Sources/Keyty/App/Composition/AppUIContainer.swift @@ -6,8 +6,6 @@ // SPDX-License-Identifier: BSD-3-Clause // -import Sparkle - @MainActor final class AppUIContainer { let aboutWindowController: AboutWindowController @@ -18,7 +16,7 @@ final class AppUIContainer { init( settings: AppSettingsContainer, services: AppServiceContainer, - updater: SPUUpdater + updateService: any UpdateService ) { self.aboutWindowController = AboutWindowController() let keyboardVisualizerPlacementWindowController = KeyboardVisualizerPlacementWindowController( @@ -34,7 +32,7 @@ final class AppUIContainer { pointerRingVisualizer: services.pointerVisualizersManager.ring, pointerRipplesVisualizer: services.pointerVisualizersManager.ripples, permissionsService: services.permissionsService, - updater: updater, + updateService: updateService, placementCoordinator: keyboardVisualizerPlacementWindowController ) let settingsWindowController = SettingsWindowController(context: settingsContext) diff --git a/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift b/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift index 0e9a3be0..8f758c2e 100644 --- a/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift +++ b/Apps/Keyty/Sources/Keyty/App/Lifecycle/AppController.swift @@ -7,22 +7,20 @@ // import AppKit + +#if !APP_STORE import AppMover -import Sparkle +#endif @MainActor final class AppController: NSObject { private let menuController: MenuController private let statusItemController: StatusItemController private let dependencies: AppDependencies - private let updaterController: SPUStandardUpdaterController + private let updateService: any UpdateService override init() { - self.updaterController = SPUStandardUpdaterController( - startingUpdater: true, - updaterDelegate: nil, - userDriverDelegate: nil - ) + self.updateService = UpdateServiceFactory.make() self.menuController = MenuController() let statusShortcutItem = self.menuController.makeStatusShortcutMenuItem() self.statusItemController = StatusItemController( @@ -31,7 +29,7 @@ final class AppController: NSObject { ) self.dependencies = AppDependencies( statusShortcutItem: statusShortcutItem, - updater: self.updaterController.updater + updateService: self.updateService ) super.init() self.menuController.setAppController(self) @@ -51,7 +49,7 @@ extension AppController: NSApplicationDelegate { NSApp.activate(ignoringOtherApps: true) - #if !DEBUG + #if !DEBUG && !APP_STORE if AppMover.moveIfNecessary() { return } @@ -86,8 +84,8 @@ extension AppController: NSApplicationDelegate { // MARK: - Settings Presentation private extension AppController { func checkForUpdatesAtLaunchIfNeeded() { - guard self.updaterController.updater.automaticallyChecksForUpdates else { return } - self.updaterController.updater.checkForUpdatesInBackground() + guard self.updateService.automaticallyChecksForUpdates else { return } + self.updateService.checkForUpdatesInBackground() } func showSettingsAtLaunchIfNeeded() { diff --git a/Apps/Keyty/Sources/Keyty/Features/About/AboutWindowViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/About/AboutWindowViewModel.swift index 6b2d3d9d..30a307b0 100644 --- a/Apps/Keyty/Sources/Keyty/Features/About/AboutWindowViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/About/AboutWindowViewModel.swift @@ -72,28 +72,7 @@ final class AboutWindowViewModel: ObservableObject { ] ) ] - self.credits = [ - Section( - title: "Sparkle", - subtitle: L10n.About.Credits.sparkleSubtitle, - body: LicenseLoader.text( - named: "Sparkle", - trimmingLeadingLines: [ - "Sparkle", - "The MIT License (MIT)" - ] - ) - ), - Section( - title: "AppMover", - subtitle: L10n.About.Credits.appMoverSubtitle, - body: LicenseLoader.text( - named: "AppMover", - trimmingLeadingLines: [ - "MIT License" - ] - ) - ), + var credits = [ Section( title: "ShortcutRecorder", subtitle: L10n.About.Credits.shortcutRecorderSubtitle, @@ -105,6 +84,37 @@ final class AboutWindowViewModel: ObservableObject { ) ) ] + + #if !APP_STORE + credits.insert( + contentsOf: [ + Section( + title: "Sparkle", + subtitle: L10n.About.Credits.sparkleSubtitle, + body: LicenseLoader.text( + named: "Sparkle", + trimmingLeadingLines: [ + "Sparkle", + "The MIT License (MIT)" + ] + ) + ), + Section( + title: "AppMover", + subtitle: L10n.About.Credits.appMoverSubtitle, + body: LicenseLoader.text( + named: "AppMover", + trimmingLeadingLines: [ + "MIT License" + ] + ) + ), + ], + at: 0 + ) + #endif + + self.credits = credits } var selectedSections: [Section] { diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsContext.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsContext.swift index cf66a738..01a4e042 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsContext.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsContext.swift @@ -6,8 +6,6 @@ // SPDX-License-Identifier: BSD-3-Clause // -import Sparkle - @MainActor final class SettingsContext { let settings: AppSettingsContainer @@ -15,7 +13,7 @@ final class SettingsContext { let pointerRingVisualizer: PointerRingVisualizer let pointerRipplesVisualizer: PointerRipplesVisualizer let permissionsService: any PermissionsService - let updater: SPUUpdater + let updateService: any UpdateService let placementCoordinator: any KeyboardVisualizerPlacementCoordinating var appSettings: any AppSettingsProtocol { self.settings.appSettings } @@ -30,7 +28,7 @@ final class SettingsContext { pointerRingVisualizer: PointerRingVisualizer, pointerRipplesVisualizer: PointerRipplesVisualizer, permissionsService: any PermissionsService, - updater: SPUUpdater, + updateService: any UpdateService, placementCoordinator: any KeyboardVisualizerPlacementCoordinating ) { self.settings = settings @@ -38,7 +36,7 @@ final class SettingsContext { self.pointerRingVisualizer = pointerRingVisualizer self.pointerRipplesVisualizer = pointerRipplesVisualizer self.permissionsService = permissionsService - self.updater = updater + self.updateService = updateService self.placementCoordinator = placementCoordinator } diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift index d62d2c29..984a5d1c 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsPaneRegistry.swift @@ -6,7 +6,6 @@ // SPDX-License-Identifier: BSD-3-Clause // -import Sparkle import SwiftUI struct SettingsPaneEntry: Identifiable { @@ -21,7 +20,7 @@ struct SettingsPaneRegistry { let entries: [SettingsPaneEntry] init(context: SettingsContext, displaysViewModel: DisplaysSettingsPaneViewModel) { - self.entries = [ + var entries = [ SettingsPaneEntry( id: .general, title: SettingsPaneIdentifier.general.label, @@ -82,14 +81,22 @@ struct SettingsPaneRegistry { AnyView(PermissionsSettingsPane(permissionsService: context.permissionsService)) } ), + ] + + #if !APP_STORE + entries.append( SettingsPaneEntry( id: .update, title: SettingsPaneIdentifier.update.label, systemImageName: SettingsPaneIdentifier.update.sfSymbolName, makeView: { - AnyView(UpdateSettingsPane(updater: context.updater)) + AnyView(UpdateSettingsPane(updateService: context.updateService)) } - ), + ) + ) + #endif + + entries.append( SettingsPaneEntry( id: .info, title: SettingsPaneIdentifier.info.label, @@ -97,8 +104,10 @@ struct SettingsPaneRegistry { makeView: { AnyView(InfoSettingsPane()) } - ), - ] + ) + ) + + self.entries = entries } func entry(for identifier: SettingsPaneIdentifier) -> SettingsPaneEntry? { diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift index 0930ac15..6a68600b 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/SettingsWindowController.swift @@ -8,7 +8,6 @@ import AppKit import Combine -import Sparkle import SwiftUI @MainActor diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Update/UpdateSettingsPane.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Update/UpdateSettingsPane.swift index 56c9aefc..65c87a81 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Update/UpdateSettingsPane.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Update/UpdateSettingsPane.swift @@ -6,14 +6,13 @@ // SPDX-License-Identifier: BSD-3-Clause // -import Sparkle import SwiftUI struct UpdateSettingsPane: View { @StateObject private var model: UpdateSettingsPaneViewModel - init(updater: SPUUpdater) { - _model = StateObject(wrappedValue: UpdateSettingsPaneViewModel(updater: updater)) + init(updateService: any UpdateService) { + _model = StateObject(wrappedValue: UpdateSettingsPaneViewModel(updateService: updateService)) } var body: some View { diff --git a/Apps/Keyty/Sources/Keyty/Features/Settings/Update/UpdateSettingsPaneViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/Settings/Update/UpdateSettingsPaneViewModel.swift index edb9a275..4282aade 100644 --- a/Apps/Keyty/Sources/Keyty/Features/Settings/Update/UpdateSettingsPaneViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/Settings/Update/UpdateSettingsPaneViewModel.swift @@ -6,9 +6,9 @@ // SPDX-License-Identifier: BSD-3-Clause // -import Sparkle import SwiftUI +@MainActor final class UpdateSettingsPaneViewModel: ObservableObject { private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -16,31 +16,31 @@ final class UpdateSettingsPaneViewModel: ObservableObject { return formatter }() - private let updater: SPUUpdater + private let updateService: any UpdateService @Published var automaticallyChecksForUpdates: Bool { - didSet { self.updater.automaticallyChecksForUpdates = self.automaticallyChecksForUpdates } + didSet { self.updateService.automaticallyChecksForUpdates = self.automaticallyChecksForUpdates } } @Published var sendsSystemProfile: Bool { - didSet { self.updater.sendsSystemProfile = self.sendsSystemProfile } + didSet { self.updateService.sendsSystemProfile = self.sendsSystemProfile } } - init(updater: SPUUpdater) { - self.updater = updater - self.automaticallyChecksForUpdates = self.updater.automaticallyChecksForUpdates - self.sendsSystemProfile = self.updater.sendsSystemProfile + init(updateService: any UpdateService) { + self.updateService = updateService + self.automaticallyChecksForUpdates = self.updateService.automaticallyChecksForUpdates + self.sendsSystemProfile = self.updateService.sendsSystemProfile } var lastCheckedText: String { - guard let date = self.updater.lastUpdateCheckDate else { + guard let date = self.updateService.lastUpdateCheckDate else { return L10n.Update.never } return Self.dateFormatter.string(from: date) } func checkForUpdates() { - self.updater.checkForUpdates() + self.updateService.checkForUpdates() self.objectWillChange.send() } } diff --git a/Apps/Keyty/Sources/Keyty/Resources/AppStore-Info.plist b/Apps/Keyty/Sources/Keyty/Resources/AppStore-Info.plist new file mode 100644 index 00000000..1c1793fa --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Resources/AppStore-Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + en_US + CFBundleExecutable + ${EXECUTABLE_NAME} + CFBundleIconFile + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${PRODUCT_NAME} + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleSignature + ???? + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + NSHumanReadableCopyright + Copyright © 2026 Serhii Bykov + NSPrincipalClass + NSApplication + NSSupportsAutomaticGraphicsSwitching + + + diff --git a/Apps/Keyty/Sources/Keyty/Services/Updates/UpdateService.swift b/Apps/Keyty/Sources/Keyty/Services/Updates/UpdateService.swift new file mode 100644 index 00000000..6ffc4af8 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Services/Updates/UpdateService.swift @@ -0,0 +1,91 @@ +// +// UpdateService.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Foundation + +#if !APP_STORE +import Sparkle +#endif + +@MainActor +protocol UpdateService: AnyObject { + var automaticallyChecksForUpdates: Bool { get set } + var sendsSystemProfile: Bool { get set } + var lastUpdateCheckDate: Date? { get } + + func checkForUpdates() + func checkForUpdatesInBackground() +} + +@MainActor +enum UpdateServiceFactory { + static func make() -> any UpdateService { + #if APP_STORE + return NoOpUpdateService() + #else + return SparkleUpdateService() + #endif + } +} + +#if APP_STORE +@MainActor +/// App Store distributions receive updates through macOS rather than an in-app updater. +private final class NoOpUpdateService: UpdateService { + var automaticallyChecksForUpdates: Bool { + get { false } + set {} + } + + var sendsSystemProfile: Bool { + get { false } + set {} + } + + let lastUpdateCheckDate: Date? = nil + + func checkForUpdates() {} + + func checkForUpdatesInBackground() {} +} +#else +@MainActor +private final class SparkleUpdateService: UpdateService { + private let controller: SPUStandardUpdaterController + + init() { + self.controller = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: nil, + userDriverDelegate: nil + ) + } + + var automaticallyChecksForUpdates: Bool { + get { self.controller.updater.automaticallyChecksForUpdates } + set { self.controller.updater.automaticallyChecksForUpdates = newValue } + } + + var sendsSystemProfile: Bool { + get { self.controller.updater.sendsSystemProfile } + set { self.controller.updater.sendsSystemProfile = newValue } + } + + var lastUpdateCheckDate: Date? { + self.controller.updater.lastUpdateCheckDate + } + + func checkForUpdates() { + self.controller.updater.checkForUpdates() + } + + func checkForUpdatesInBackground() { + self.controller.updater.checkForUpdatesInBackground() + } +} +#endif From 00262ec752c3ccfe3bc9ecf3ea90c1fad531e5ad Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 4 Sep 2026 13:17:57 +0200 Subject: [PATCH 4/6] style(about): format distribution credits --- .../Features/About/AboutWindowViewModel.swift | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/Apps/Keyty/Sources/Keyty/Features/About/AboutWindowViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/About/AboutWindowViewModel.swift index 30a307b0..0cb31157 100644 --- a/Apps/Keyty/Sources/Keyty/Features/About/AboutWindowViewModel.swift +++ b/Apps/Keyty/Sources/Keyty/Features/About/AboutWindowViewModel.swift @@ -208,44 +208,44 @@ extension AboutWindowViewModel { extension AboutWindowViewModel { enum LicenseLoader { - static func text( - named resourceName: String, - trimmingLeadingLines linesToTrim: [String] = [], - bundle: Bundle = .main - ) -> String { - let candidates = [ - bundle.url(forResource: resourceName, withExtension: "txt", subdirectory: "AboutLicenses"), - bundle.url(forResource: resourceName, withExtension: "txt") - ] - - for candidate in candidates { - guard let url = candidate else { continue } - if let text = try? String(contentsOf: url, encoding: .utf8) { - return Self.sanitized(text, trimmingLeadingLines: linesToTrim) + static func text( + named resourceName: String, + trimmingLeadingLines linesToTrim: [String] = [], + bundle: Bundle = .main + ) -> String { + let candidates = [ + bundle.url(forResource: resourceName, withExtension: "txt", subdirectory: "AboutLicenses"), + bundle.url(forResource: resourceName, withExtension: "txt") + ] + + for candidate in candidates { + guard let url = candidate else { continue } + if let text = try? String(contentsOf: url, encoding: .utf8) { + return Self.sanitized(text, trimmingLeadingLines: linesToTrim) + } } - } - - return L10n.About.Credits.licenseMissing - } - - private static func sanitized(_ text: String, trimmingLeadingLines linesToTrim: [String]) -> String { - var lines = text.components(separatedBy: .newlines) - while let first = lines.first, first.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - lines.removeFirst() + return L10n.About.Credits.licenseMissing } - for lineToTrim in linesToTrim { - guard let first = lines.first else { break } - if first.trimmingCharacters(in: .whitespacesAndNewlines) == lineToTrim { + private static func sanitized(_ text: String, trimmingLeadingLines linesToTrim: [String]) -> String { + var lines = text.components(separatedBy: .newlines) + + while let first = lines.first, first.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { lines.removeFirst() - while let next = lines.first, next.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + } + + for lineToTrim in linesToTrim { + guard let first = lines.first else { break } + if first.trimmingCharacters(in: .whitespacesAndNewlines) == lineToTrim { lines.removeFirst() + while let next = lines.first, next.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + lines.removeFirst() + } } } - } - return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) - } + return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) + } } } From 7fb651b8faae329c7ee917301cc936b4f119103b Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 4 Sep 2026 19:47:58 +0200 Subject: [PATCH 5/6] chore(project): disable automatic scheme generation --- Apps/Keyty/Project.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Apps/Keyty/Project.swift b/Apps/Keyty/Project.swift index bd233048..25c9f5f6 100644 --- a/Apps/Keyty/Project.swift +++ b/Apps/Keyty/Project.swift @@ -198,6 +198,7 @@ let project = Project( name: "Keyty", organizationName: "Keyty", options: .options( + automaticSchemesOptions: .disabled, defaultKnownRegions: ["de", "en", "es", "fr", "it", "ja", "ko", "nl", "pt-BR", "uk", "zh-Hans"], developmentRegion: "en" ), From 9c8309a58f185b854757b7bbf0de2de74bd6fb18 Mon Sep 17 00:00:00 2001 From: Serhii Bykov Date: Fri, 4 Sep 2026 19:56:11 +0200 Subject: [PATCH 6/6] feat(permissions): open Accessibility settings when requesting access --- .../Keyty/Platform/Permissions/Permission.swift | 1 + .../NSWorkspace+AccessibilitySettings.swift | 15 +++++++++++++++ .../Foundation/URL+AccessibilitySettings.swift | 15 +++++++++++++++ .../PermissionsOnboardingViewModelTests.swift | 2 +- 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 Apps/Keyty/Sources/Keyty/Support/Extensions/AppKit/NSWorkspace+AccessibilitySettings.swift create mode 100644 Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/URL+AccessibilitySettings.swift diff --git a/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift b/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift index 7c3cef87..98d370a5 100644 --- a/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift +++ b/Apps/Keyty/Sources/Keyty/Platform/Permissions/Permission.swift @@ -19,6 +19,7 @@ enum Permission: CaseIterable, Hashable { func requestSystemPermission() { let key = kAXTrustedCheckOptionPrompt.takeRetainedValue() as String AXIsProcessTrustedWithOptions([key: true] as CFDictionary) + NSWorkspace.shared.openAccessibilitySettings() } } diff --git a/Apps/Keyty/Sources/Keyty/Support/Extensions/AppKit/NSWorkspace+AccessibilitySettings.swift b/Apps/Keyty/Sources/Keyty/Support/Extensions/AppKit/NSWorkspace+AccessibilitySettings.swift new file mode 100644 index 00000000..85301d2d --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Support/Extensions/AppKit/NSWorkspace+AccessibilitySettings.swift @@ -0,0 +1,15 @@ +// +// NSWorkspace+AccessibilitySettings.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import AppKit + +extension NSWorkspace { + func openAccessibilitySettings() { + self.open(.accessibilitySettings) + } +} diff --git a/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/URL+AccessibilitySettings.swift b/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/URL+AccessibilitySettings.swift new file mode 100644 index 00000000..518e42a0 --- /dev/null +++ b/Apps/Keyty/Sources/Keyty/Support/Extensions/Foundation/URL+AccessibilitySettings.swift @@ -0,0 +1,15 @@ +// +// URL+AccessibilitySettings.swift +// Keyty +// +// SPDX-FileCopyrightText: 2026 Serhii Bykov +// SPDX-License-Identifier: BSD-3-Clause +// + +import Foundation + +extension URL { + static let accessibilitySettings = URL( + string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" + )! +} diff --git a/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift b/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift index 9df78f58..3767b23a 100644 --- a/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift +++ b/Apps/Keyty/Tests/KeytyTests/Features/PermissionsOnboarding/PermissionsOnboardingViewModelTests.swift @@ -26,7 +26,7 @@ final class PermissionsOnboardingViewModelTests: XCTestCase { XCTAssertFalse(model.isComplete) } - func testRequestAccessibilityRequestsPermissionWithoutOpeningSettings() { + func testRequestAccessibilityForwardsAccessibilityRequest() { let service = TestPermissionsService() let model = PermissionsOnboardingViewModel(permissionsService: service)