diff --git a/Apps/Keyty/Project.swift b/Apps/Keyty/Project.swift
index 8200edd7..25c9f5f6 100644
--- a/Apps/Keyty/Project.swift
+++ b/Apps/Keyty/Project.swift
@@ -55,6 +55,27 @@ 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",
+ "PRODUCT_BUNDLE_IDENTIFIER": "app.keyty.Keyty.AppStore",
+ "SWIFT_ACTIVE_COMPILATION_CONDITIONS": [
+ "$(inherited)",
+ "APP_STORE",
+ ],
+]
+
+let appStoreDebugSettings = appStoreSettings.merging([
+ "PRODUCT_NAME": "KeytyAppStore",
+]) { _, appStoreDebugValue in
+ appStoreDebugValue
+}
+
let testSettings: SettingsDictionary = [
"BUNDLE_LOADER": "$(TEST_HOST)",
"CLANG_ANALYZER_NONNULL": "YES",
@@ -164,6 +185,12 @@ let projectSettings = Settings.settings(
"SWIFT_COMPILATION_MODE": "wholemodule",
]
),
+ .release(
+ name: "AppStore",
+ settings: [
+ "SWIFT_COMPILATION_MODE": "wholemodule",
+ ]
+ ),
]
)
@@ -171,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"
),
@@ -204,7 +232,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"),
@@ -225,6 +256,38 @@ let project = Project(
]
)
),
+ .target(
+ name: "KeytyAppStore",
+ destinations: .macOS,
+ product: .app,
+ bundleId: "app.keyty.Keyty.AppStore",
+ deploymentTargets: .macOS("11.0"),
+ infoPlist: .file(path: "Sources/Keyty/Resources/AppStore-Info.plist"),
+ buildableFolders: [
+ .folder(
+ "Sources/Keyty",
+ exceptions: [.exception(excluded: [
+ "Resources/Info.plist",
+ "Resources/AppStore-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"),
+ ],
+ settings: .settings(
+ base: appSettings,
+ configurations: [
+ .debug(name: "Debug", settings: appStoreDebugSettings),
+ .release(name: "AppStore", settings: appStoreSettings),
+ ]
+ )
+ ),
.target(
name: "KeytyTests",
destinations: .macOS,
@@ -265,6 +328,15 @@ let project = Project(
profileAction: .profileAction(configuration: .release),
analyzeAction: .analyzeAction(configuration: .debug)
),
+ .scheme(
+ name: "Keyty AppStore",
+ shared: true,
+ buildAction: .buildAction(targets: ["KeytyAppStore"]),
+ 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/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/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
diff --git a/Apps/Keyty/Sources/Keyty/Features/About/AboutWindowViewModel.swift b/Apps/Keyty/Sources/Keyty/Features/About/AboutWindowViewModel.swift
index 6b2d3d9d..0cb31157 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] {
@@ -198,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)
+ }
}
}
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/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/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/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/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
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)
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